';
}
// Per-card action menu, built from the ui.library-card-injection registry
// (core Edit/Retune + any plugin-registered actions).
let _closeCardMenu = null; // tears down the currently-open card menu + its document closer
function openCardMenu(cardEl, song, anchorBtn) {
// Fully close any already-open menu first — removing just the DOM node
// (as before) would orphan its document-level click closer.
if (_closeCardMenu) _closeCardMenu();
const reg = sm && sm.libraryCardActions;
// Only show actions intended for the overflow menu — actions placed
// 'inline'/'overlay' get their own affordances on the card (see songCard).
// Undefined placement defaults to the menu.
const items = (reg ? reg.list(song) : []).filter((a) => !a.placement || a.placement === 'menu');
const menu = document.createElement('div');
menu.className = 'v3-card-menu absolute top-10 right-2 z-30 min-w-[10rem] bg-fb-card border border-fb-border/60 rounded-lg shadow-xl py-1 text-sm';
const rows = [
{ id: '__play', label: 'Play', run: () => { _saveLibraryScrollSnapshot(); window.playSong && window.playSong(enc(song.filename)); } },
...items.map((a) => ({ id: a.id, label: a.label, destructive: a.destructive, enabled: a.enabled, plugin: a.pluginId })),
];
menu.innerHTML = rows.map((r) =>
'').join('');
cardEl.appendChild(menu);
// Tear down BOTH the menu and its document-level closer together, so a
// menu-item click doesn't leave the closer attached (it would otherwise
// leak, retaining this menu's closures until the next document click).
const closer = (e) => { if (!menu.contains(e.target) && e.target !== anchorBtn) closeMenu(); };
function closeMenu() { menu.remove(); document.removeEventListener('click', closer); if (_closeCardMenu === closeMenu) _closeCardMenu = null; }
_closeCardMenu = closeMenu;
menu.querySelectorAll('[data-act]').forEach((b) => b.addEventListener('click', async (e) => {
e.stopPropagation();
const id = b.getAttribute('data-act');
closeMenu();
if (id === '__play') { playCard(song); return; }
if (reg) await reg.run(id, song, { source: 'v3-songs' });
}));
setTimeout(() => document.addEventListener('click', closer), 0);
}
function wireCards(scope) {
scope.querySelectorAll('[data-fn]').forEach((el) => {
if (el.dataset.wired) return; // don't double-bind on append/auto-fill
el.dataset.wired = '1';
const fn = el.getAttribute('data-fn');
const song = state.songsById[fn] || { filename: fn };
el.querySelectorAll('[data-v3-play]').forEach((pe) => pe.addEventListener('click', (e) => {
if (state.selectMode) { e.preventDefault(); toggleSelect(fn, el); return; }
playCard(song); // local → play; unsynced remote → sync then play
}));
el.querySelector('[data-menu]')?.addEventListener('click', (e) => { e.stopPropagation(); openCardMenu(el, song, e.currentTarget); });
el.querySelectorAll('[data-arr]').forEach((ab) => ab.addEventListener('click', (e) => {
e.stopPropagation();
const idx = ab.getAttribute('data-arr');
playCard(song, idx === '' ? undefined : Number(idx));
}));
el.querySelector('[data-fav]')?.addEventListener('click', async (e) => {
e.stopPropagation();
const btn = e.currentTarget;
try {
const r = await fetch('/api/favorites/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) });
const d = await r.json();
btn.textContent = d.favorite ? '♥' : '♡';
btn.setAttribute('aria-pressed', d.favorite ? 'true' : 'false');
btn.classList.toggle('text-fb-accent', d.favorite);
btn.classList.toggle('text-white', !d.favorite);
} catch (err) { /* */ }
});
el.querySelector('[data-save]')?.addEventListener('click', async (e) => {
e.stopPropagation();
if (window.v3Saved) { const saved = await window.v3Saved.toggle(fn); e.currentTarget.classList.toggle('text-fb-primary', !!saved); }
});
// Inline/overlay plugin card actions → run via the shared registry.
el.querySelectorAll('[data-act-card]').forEach((ab) => ab.addEventListener('click', async (e) => {
e.stopPropagation();
const reg = sm && sm.libraryCardActions;
if (reg) await reg.run(ab.getAttribute('data-act-card'), song, { source: 'v3-songs' });
}));
});
}
// ── Multi-select + batch actions ──────────────────────────────────────--
function toggleSelect(fn, el) {
if (state.selected.has(fn)) state.selected.delete(fn); else state.selected.add(fn);
const on = state.selected.has(fn);
const cb = el.querySelector('[data-select]'); if (cb) cb.checked = on;
el.querySelector('[data-v3-play]')?.classList.toggle('ring-2', on);
el.querySelector('[data-v3-play]')?.classList.toggle('ring-fb-primary', on);
renderBatchBar();
}
function setSelectMode(on) {
state.selectMode = on;
if (!on) state.selected.clear();
const btn = document.getElementById('v3-songs-select');
if (btn) btn.className = btnCtrl + (on ? ' bg-fb-primary text-white' : '');
reload(); // re-render cards with/without checkboxes
renderBatchBar();
}
function renderBatchBar() {
let bar = document.getElementById('v3-songs-batch');
if (!state.selectMode || state.selected.size === 0) { if (bar) bar.remove(); return; }
if (!bar) {
bar = document.createElement('div');
bar.id = 'v3-songs-batch';
bar.className = 'fixed bottom-4 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 bg-fb-card border border-fb-border/60 rounded-full shadow-xl px-4 py-2';
document.body.appendChild(bar);
}
bar.innerHTML =
'' + state.selected.size + ' selected' +
'' +
'' +
'';
bar.querySelector('[data-batch="clear"]').addEventListener('click', () => { state.selected.clear(); reload(); renderBatchBar(); });
bar.querySelector('[data-batch="saved"]').addEventListener('click', batchSave);
bar.querySelector('[data-batch="playlist"]').addEventListener('click', batchAddToPlaylist);
}
async function batchSave() {
const lists = (await jget('/api/playlists')) || [];
const saved = lists.find((p) => p.system_key === 'saved_for_later');
let present = new Set();
if (saved) { const pl = await jget('/api/playlists/' + saved.id); present = new Set(((pl && pl.songs) || []).map((s) => s.filename)); }
// Additive: only toggle (add) songs not already saved.
for (const fn of state.selected) {
if (!present.has(fn)) {
try { await fetch('/api/saved/toggle', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
}
finishBatch();
}
async function batchAddToPlaylist() {
const lists = (await jget('/api/playlists')) || [];
const choices = lists.filter((p) => !p.system_key);
const labels = choices.map((p, i) => (i + 1) + '. ' + p.name).join('\n');
const ans = (window.prompt('Add ' + state.selected.size + ' song(s) to which playlist?\n' + labels + '\n\nEnter a number, or a new playlist name:', '') || '').trim();
if (!ans) return;
let pid = null;
const num = parseInt(ans, 10);
if (!isNaN(num) && choices[num - 1]) pid = choices[num - 1].id;
else { const created = await jsend('POST', '/api/playlists', { name: ans }); pid = created && created.id; }
if (!pid) return;
for (const fn of state.selected) {
try { await fetch('/api/playlists/' + pid + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); } catch (e) { /* */ }
}
finishBatch();
}
function finishBatch() {
state.selected.clear();
if (window.v3Playlists) { try { window.v3Playlists.refresh(); window.v3Playlists.refreshSaved(); } catch (e) { /* */ } }
reload(); renderBatchBar();
}
async function jsend(method, url, body) {
try { const r = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); return r.ok ? r.json() : null; } catch (e) { return null; }
}
// ── Grid (paged + infinite scroll) ─────────────────────────────────────--
async function loadGrid(reset) {
// A reset requested mid-fetch (provider/sort/filter/search change) must
// not be dropped — remember it and re-run once the in-flight load
// returns, otherwise the stale response repopulates the grid.
if (state.loading) { if (reset) state.pendingReset = true; return; }
const grid = document.getElementById('v3-songs-grid');
if (!grid) return;
// A reset wipes the grid (and any open card menu's DOM); close the menu
// first so its document-level click closer doesn't leak.
if (reset) { if (_closeCardMenu) _closeCardMenu(); state.page = 0; state.total = 0; grid.innerHTML = ''; }
state.loading = true;
const data = await jget('/api/library?' + queryParams({ page: state.page, size: PAGE_SIZE }).toString());
state.loading = false;
if (state.pendingReset) { state.pendingReset = false; return loadGrid(true); }
if (!data) return;
state.total = data.total || 0;
(data.songs || []).forEach((s) => { state.songsById[cardKey(s)] = s; grid.insertAdjacentHTML('beforeend', songCard(s)); });
wireCards(grid);
const countEl = document.getElementById('v3-songs-count');
if (countEl) countEl.textContent = state.total + ' song' + (state.total === 1 ? '' : 's');
const loaded = grid.querySelectorAll('[data-fn]').length;
const sentinel = document.getElementById('v3-songs-sentinel');
if (sentinel) sentinel.style.display = loaded < state.total ? 'block' : 'none';
// Auto-fill: if the grid doesn't yet overflow the scroller, keep loading
// (so a short first page still becomes scrollable without user action).
maybeFill();
}
function loadedCount() { return document.querySelectorAll('#v3-songs-grid [data-fn]').length; }
// The scroll listener lives on the SHARED #v3-main container, so guard every
// paging entry point on the Songs screen actually being active — otherwise
// scrolling another screen would keep fetching /api/library into the hidden
// grid after Songs has been visited once.
function songsActive() { const el = document.getElementById('v3-songs'); return !!el && el.classList.contains('active'); }
function loadNext() {
if (state.loading || state.view !== 'grid' || !songsActive()) return;
if (loadedCount() < state.total) { state.page++; loadGrid(false); }
}
function maybeFill() {
const main = document.getElementById('v3-main');
if (!main || state.view !== 'grid' || state.loading || !songsActive()) return;
// Not tall enough to scroll yet, and more remain → pull the next page.
if (main.scrollHeight <= main.clientHeight + 80 && loadedCount() < state.total) loadNext();
}
// Robust infinite scroll: a scroll listener on the real scroll container
// (#v3-main), bound once. Avoids the IntersectionObserver "already in view
// at observe-time" race that stuck the grid on page 0.
function bindScroll() {
const main = document.getElementById('v3-main');
if (!main || state.scrollBound) return;
state.scrollBound = true;
main.addEventListener('scroll', () => {
if (state.view !== 'grid' || state.loading) return;
if (main.scrollTop + main.clientHeight >= main.scrollHeight - 600) loadNext();
}, { passive: true });
}
// ── Tree ────────────────────────────────────────────────────────────────
async function loadTree() {
const host = document.getElementById('v3-songs-tree');
if (!host) return;
host.innerHTML = '
Loading…
';
// Page through ALL artists — the endpoint clamps size to 100, so a
// single request would silently truncate libraries with >100 artists.
const artists = [];
let page = 0, total = Infinity;
while (artists.length < total) {
const data = await jget('/api/library/artists?' + queryParams({ size: 100, page }).toString());
if (!data || !Array.isArray(data.artists)) break;
artists.push(...data.artists);
total = (data.total_artists != null) ? data.total_artists : artists.length;
if (!data.artists.length || page > 1000) break; // safety: no progress / runaway guard
page++;
}
if (!artists.length) { host.innerHTML = '