';
}
// The slot's pinned-arrangement INDEX, resolved from the stored NAME
// against the slot's current chart (names survive rescans; an index
// wouldn't). null = no pin / the name isn't on this chart โ full song.
function _slotArrIndex(s) {
if (!s.arrangement || !Array.isArray(s.arrangements)) return null;
const m = s.arrangements.find((a) => a && (a.smart_name === s.arrangement || a.name === s.arrangement));
return (m && m.index != null) ? m.index : null;
}
// โโ Playlist tuning check โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Playlists are commonly grouped BY TUNING so a practice run needs no
// retune mid-session (retuning a bass is minutes of settling, and detuning
// far on standard gauges goes floppy). A playlist built before the tuning
// filter knew about your instrument can hold songs you can't actually play
// without stopping. This flags them. It is READ-ONLY: nothing here edits a
// playlist โ removal is a separate, explicit, itemised action.
// Pick the indexed perspective that matches the player's live instrument.
// #1003 supplies bass-specific columns; when a song has no bass chart we
// deliberately fall back to the historical song-level guitar tuning.
function rowTuningForCheck(s) {
let wantsBass = false;
try {
const wt = window.feedBack && window.feedBack.workingTuning;
const cur = wt && typeof wt.get === 'function' ? wt.get() : null;
wantsBass = !!cur && cur.instrument === 'bass';
} catch (_) { /* capability errors degrade to the song-level tuning */ }
const hasBassTuning = wantsBass && !!s.bass_tuning_offsets;
return {
offsets: hasBassTuning
? s.bass_tuning_offsets : (s.tuning_offsets || s.tuning_name),
// The selected bass perspective uses bass base pitches. A bass-only
// fallback row does too; every other fallback is the lead chart.
isBass: hasBassTuning || !!s.bass_only,
};
}
// A coverage report says "not covered" BOTH for a real mismatch and for
// "I couldn't work it out" (missing settings/tuner data โ an all-empty
// report). Only a report carrying an actual reason โ named string changes,
// a reference-pitch gap, or too few strings โ is a mismatch. An unexplained
// not-covered is UNKNOWN. A false "wrong tuning" on a hand-curated playlist
// costs more trust than saying nothing.
function tuningStateFromReport(rep) {
if (!rep) return 'unknown';
if (rep.covered) return 'match';
if (rep.cantCover || rep.reference
|| (Array.isArray(rep.retune) && rep.retune.length)) return 'mismatch';
return 'unknown';
}
// Score every row. Returns null when the host exposes no tuning perspective
// at all (no working-tuning capability / no tuner coverage) โ the caller
// then renders the playlist exactly as before rather than claiming anything.
async function checkPlaylistTuning(songs) {
const cov = window._tunerAutoOpen && window._tunerAutoOpen.coverageReport;
const hasWT = window.feedBack && window.feedBack.workingTuning
&& typeof window.feedBack.workingTuning.get === 'function';
if (typeof cov !== 'function' || !hasWT) return null;
const parse = window.parseRawTuningOffsets;
const out = [];
for (const s of songs || []) {
const t = rowTuningForCheck(s);
const offs = (typeof parse === 'function') ? parse(t.offsets) : null;
if (!offs || !offs.length || offs.some((n) => !isFinite(n))) {
out.push({ song: s, state: 'unknown' });
continue;
}
let rep = null;
try {
rep = await cov({
tuning: offs, stringCount: offs.length,
arrangement: t.isBass ? 'Bass' : 'Lead',
});
} catch (_) { rep = null; }
out.push({ song: s, state: tuningStateFromReport(rep) });
}
return out;
}
// Colour + a TEXT marker per state โ unknown is deliberately neutral-and-
// dimmed rather than amber, because "I couldn't check this" is a different
// claim from "this is the wrong tuning" and must not read as the latter.
function paintTuningChip(chip, state) {
if (!chip) return;
if (chip.dataset.baseTitle == null) chip.dataset.baseTitle = chip.getAttribute('title') || '';
chip.classList.remove('bg-fb-mid', 'bg-emerald-500', 'bg-amber-400', 'opacity-60');
chip.classList.add(state === 'match' ? 'bg-emerald-500'
: state === 'mismatch' ? 'bg-amber-400' : 'bg-fb-mid');
if (state === 'unknown') chip.classList.add('opacity-60');
chip.setAttribute('title', chip.dataset.baseTitle + (state === 'match'
? ' โ matches your tuning'
: state === 'mismatch' ? ' โ needs a retune'
: ' โ no tuning data, not checked'));
// Never signal by colour alone.
const mark = state === 'mismatch' ? ' โ ' : state === 'unknown' ? ' ?' : '';
let m = chip.querySelector('[data-tuning-mark]');
if (!m) {
m = document.createElement('span');
m.setAttribute('data-tuning-mark', '');
chip.appendChild(m);
}
m.textContent = mark;
}
function tuningSummaryHtml(results) {
const total = results.length;
if (!total) return '';
const mism = results.filter((r) => r.state === 'mismatch').length;
const unk = results.filter((r) => r.state === 'unknown').length;
// Plain gap-3 rather than gap-x-3/gap-y-2: the axis-specific pair isn't
// in the committed tailwind.min.css, and regenerating it is not
// reproducible outside CI (autoprefixer/caniuse drift changes unrelated
// bytes), so the summary bar stays within the shipped class set.
const box = 'mb-4 rounded-lg border px-3 py-2 text-sm flex flex-wrap items-center gap-3 ';
if (!mism) {
return '
' +
'โ All ' + total + ' songs are in your tuning.' +
(unk ? '' + unk + ' couldn\'t be checked (no tuning data).' : '') +
'
';
}
return '
' +
'' + mism + ' of ' + total + ' songs aren\'t in your tuning.' +
(unk ? '' + unk + ' couldn\'t be checked (no tuning data) โ left alone.' : '') +
'' +
'' +
'' +
'
';
}
// Run the check and wire its affordances. Read-only: the only mutation is
// the explicit, itemised, confirmed removal below.
async function applyTuningCheck(root, pl, pid, rerender) {
const host = root.querySelector('#v3-pl-tuning');
const listEl = root.querySelector('#v3-pl-songs');
if (!host || !listEl) return;
const results = await checkPlaylistTuning(pl.songs);
if (!results) return; // no perspective โ say nothing
const rows = listEl.querySelectorAll('li[data-fn]');
results.forEach((r, i) => {
const li = rows[i];
if (!li) return;
li.setAttribute('data-tuning-state', r.state);
paintTuningChip(li.querySelector('[data-tuning-chip]'), r.state);
});
host.innerHTML = tuningSummaryHtml(results);
const onlyBtn = host.querySelector('#v3-pl-tune-only');
onlyBtn?.addEventListener('click', () => {
const on = onlyBtn.getAttribute('aria-pressed') !== 'true';
onlyBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
onlyBtn.textContent = on ? 'Show all' : 'Show only these';
rows.forEach((li) => {
li.classList.toggle('hidden', on && li.getAttribute('data-tuning-state') !== 'mismatch');
});
});
host.querySelector('#v3-pl-tune-remove')?.addEventListener('click', async () => {
// Name every song BEFORE removing anything โ a curated playlist is
// user data, so the confirm has to be a list, not a count.
const doomed = results.filter((r) => r.state === 'mismatch').map((r) => r.song);
if (!doomed.length) return;
const names = doomed.map((s) => '
โข ' + esc(s.title || s.filename) + '
').join('');
const msg = 'Remove these ' + doomed.length + ' song' + (doomed.length === 1 ? '' : 's')
+ ' from "' + esc(pl.name) + '"?'
// Bulleted with a literal โข, and sized with max-h-32, so the
// confirm needs no Tailwind class the committed CSS lacks โ
// regenerating tailwind.min.css is not reproducible off CI.
+ '
' + names + '
'
+ '
They stay in your library โ only this playlist changes, and you can add them back.
';
const ok = (typeof window.uiConfirm === 'function')
? await window.uiConfirm({
title: 'Remove mismatched songs?', html: msg,
confirmText: 'Remove ' + doomed.length, cancelText: 'Cancel', danger: true,
})
: window.confirm('Remove ' + doomed.length + ' song(s) from "' + pl.name + '"?\n\n'
+ doomed.map((s) => 'โข ' + (s.title || s.filename)).join('\n')
+ '\n\nThey stay in your library.');
if (!ok) return;
for (const s of doomed) {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(s.filename),
{ method: 'DELETE' });
}
rerender();
});
}
function songRow(s, opts) {
opts = opts || {};
const handle = opts.draggable
? 'โ ฟ' : '';
// The chip carries its own tuning so the post-paint check can colour it
// in place (green = play it now, amber = needs a retune, dimmed ? =
// couldn't tell) without re-rendering the list.
const tuning = s.tuning_name
? '' + esc(s.tuning_name) + '' : '';
// โโ Curated-album slot extras (P6) โ mixes/saved emit none of this โโ
// A slot plays its RESOLVED chart (data-play-fn: the pinned file, or
// the work's current keeper when the pinned file is gone) with its
// pinned arrangement (data-play-arr); `missing` = the whole work left
// the library, so the row dims and loses play (denominator stays
// honest). โพ opens the slot editor (chart + arrangement pin).
const isAlbum = !!opts.album;
const missing = isAlbum && !!s.missing;
const playFn = s.resolved_filename || s.filename;
const arrIdx = isAlbum ? _slotArrIndex(s) : null;
const playAttrs = isAlbum && !missing
? ' data-play-fn="' + esc(playFn) + '"' + (arrIdx != null ? ' data-play-arr="' + arrIdx + '"' : '')
: '';
const acc = (isAlbum && typeof opts.acc === 'number')
? '' + Math.floor(opts.acc * 100) + '%'
: '';
const pin = (isAlbum && s.arrangement)
? '' + esc(s.arrangement) + '' : '';
const orphan = (isAlbum && s.resolved_from_orphan)
? '(auto)' : '';
const slotBtn = (isAlbum && !missing)
? '' : '';
return '
' +
handle +
'' +
'' + esc(s.title) + tuning + pin + orphan + '' +
'' + (missing ? 'Missing โ no version of this song is in your library' : esc(s.artist)) + '' +
acc +
(missing ? '' : '') +
slotBtn +
'' +
'
';
}
function wireSongRows(listEl, pid, onChange) {
listEl.querySelectorAll('li[data-fn]').forEach((li) => {
const fn = li.getAttribute('data-fn');
li.querySelector('[data-v3-play]')?.addEventListener('click', () => {
// playSong decodeURIComponent()s its arg for the highway WS, so
// pass an encoded filename (like the rest of v3) โ a raw name
// with %/#/?/ in it would otherwise misroute or throw.
// Album slots override the play target (data-play-fn = the
// orphan-resolved chart) + pass the pinned arrangement index;
// mix/saved rows carry neither attribute and behave as before.
const pfn = li.getAttribute('data-play-fn') || fn;
const pa = li.getAttribute('data-play-arr');
if (typeof window.playSong === 'function') {
window.playSong(encodeURIComponent(pfn), pa == null ? undefined : Number(pa));
}
});
li.querySelector('[data-remove]')?.addEventListener('click', async () => {
await fetch('/api/playlists/' + pid + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' });
onChange();
});
});
// Drag-reorder.
let dragEl = null;
listEl.querySelectorAll('li[draggable="true"]').forEach((li) => {
li.addEventListener('dragstart', () => { dragEl = li; li.classList.add('opacity-50'); });
li.addEventListener('dragend', () => { li.classList.remove('opacity-50'); });
li.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragEl || dragEl === li) return;
const rect = li.getBoundingClientRect();
const after = (e.clientY - rect.top) > rect.height / 2;
li.parentNode.insertBefore(dragEl, after ? li.nextSibling : li);
});
li.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(listEl.querySelectorAll('li[data-fn]')).map((x) => x.getAttribute('data-fn'));
await jsend('POST', '/api/playlists/' + pid + '/reorder', { order });
// Re-sync from the server: if /reorder was rejected (concurrent
// change) or the request failed, the optimistic DOM order would
// otherwise diverge from what was actually persisted.
onChange();
});
});
}
// โโ #v3-playlists โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ--
async function renderPlaylists() {
const root = document.getElementById('v3-playlists');
if (!root) return;
const lists = (await jget('/api/playlists')) || [];
// Drag-to-reorder is for user playlists only โ system ones (Saved for
// Later) stay pinned first by the server ordering.
const userCount = lists.filter((p) => !p.system_key).length;
root.innerHTML =
'
' +
'
' +
// Sort AโZ: clears the manual (drag) order server-side. Only worth
// showing once there are two user playlists to order.
(userCount > 1
? '' : '') +
// Curated album (P6): a hand-picked ORDERED set with a chosen chart
// per track โ same machinery as a playlist, kind='album'.
'' +
'' +
'
' +
(lists.length
? '
' + lists.map((p) =>
'').join('') + '
'
: '
No playlists yet. Create one to group songs.
') +
'
';
root.querySelector('#v3-pl-new')?.addEventListener('click', async () => {
const name = ((await window.uiPrompt({ title: 'New Playlist', label: 'Playlist name', okLabel: 'Create', placeholder: 'My Playlist' })) || '').trim();
if (!name) return;
await jsend('POST', '/api/playlists', { name });
renderPlaylists();
});
root.querySelector('#v3-pl-new-album')?.addEventListener('click', async () => {
const name = ((await window.uiPrompt({ title: 'New Album', label: 'Album name', okLabel: 'Create', placeholder: 'My Album' })) || '').trim();
if (!name) return;
await jsend('POST', '/api/playlists', { name, kind: 'album' });
renderPlaylists();
});
root.querySelector('#v3-pl-sort-az')?.addEventListener('click', async () => {
await jsend('POST', '/api/playlists/sort-alpha');
renderPlaylists();
});
root.querySelectorAll('[data-pl]').forEach((b) =>
b.addEventListener('click', () => renderPlaylistDetail(parseInt(b.getAttribute('data-pl'), 10))));
// Drag-reorder of the playlist cards themselves (mirrors wireSongRows).
// Only user playlists carry draggable="true"; system cards are neither
// drag sources nor drop targets, so nothing can be inserted ahead of
// them (and the server pins them first regardless).
const grid = root.querySelector('#v3-pl-grid');
if (grid) {
let dragEl = null;
grid.querySelectorAll('button[data-pl][draggable="true"]').forEach((card) => {
card.addEventListener('dragstart', () => { dragEl = card; card.classList.add('opacity-50'); });
card.addEventListener('dragend', () => { card.classList.remove('opacity-50'); });
card.addEventListener('dragover', (e) => {
e.preventDefault();
if (!dragEl || dragEl === card) return;
// Grid tiles flow leftโright then wrap, so the insert side
// is horizontal (the song rows' vertical-midpoint idiom,
// rotated); moving to another row targets that row's cards.
const rect = card.getBoundingClientRect();
const after = (e.clientX - rect.left) > rect.width / 2;
card.parentNode.insertBefore(dragEl, after ? card.nextSibling : card);
});
card.addEventListener('drop', async (e) => {
e.preventDefault();
const order = Array.from(grid.querySelectorAll('button[data-pl][draggable="true"]'))
.map((x) => parseInt(x.getAttribute('data-pl'), 10));
await jsend('POST', '/api/playlists/reorder', { order });
// Re-sync from the server: if /reorder was rejected
// (concurrent change) or the request failed, the optimistic
// DOM order would otherwise diverge from what persisted.
renderPlaylists();
});
});
}
}
async function renderPlaylistDetail(pid) {
const root = document.getElementById('v3-playlists');
if (!root) return;
const pl = await jget('/api/playlists/' + pid);
if (!pl) { renderPlaylists(); return; }
const isSystem = !!pl.system_key;
const isAlbum = pl.kind === 'album';
// Set-scoped repertoire (P6, ยง7.2): an album is a bounded practice SET
// with a denominator โ "N of M mastered", per-track accuracy, never one
// album score. Same 0.9 threshold as the library meter/green badge.
let best = {};
if (isAlbum) best = (await jget('/api/stats/best')) || {};
const slotAcc = (s) => best[s.resolved_filename || s.filename];
let meter = '';
if (isAlbum && pl.songs.length) {
const tracks = pl.songs.filter((s) => !s.missing);
const mastered = tracks.filter((s) => (slotAcc(s) || 0) >= 0.9).length;
const started = tracks.filter((s) => { const b = slotAcc(s); return typeof b === 'number' && b > 0 && b < 0.9; }).length;
const pct = tracks.length ? Math.max(0, Math.min(100, Math.round((mastered / tracks.length) * 100))) : 0;
meter =
'
' +
meter +
// Filled in after paint by applyTuningCheck (async, feature-detected)
// โ stays empty when the host exposes no tuning perspective.
(pl.songs.length ? '' : '') +
(pl.songs.length
? '
Nothing saved yet. Use โSave for laterโ on a song to add it here.
') +
'
';
const listEl = root.querySelector('#v3-saved-songs');
if (listEl && pl) wireSongRows(listEl, pl.id, renderSaved);
}
// โโ Public: Save-for-later toggle for song cards/rows โโโโโโโโโโโโโโโโโ--
window.v3Saved = {
toggle: async function (filename) {
const res = await jsend('POST', '/api/saved/toggle', { filename });
return res ? res.saved : null;
},
};
window.v3Playlists = { refresh: renderPlaylists, refreshSaved: renderSaved };
// Lazy-render when these screens are shown (data can change between visits).
if (sm && typeof sm.on === 'function') {
sm.on('screen:changed', (e) => {
const id = e && e.detail && e.detail.id;
if (id === 'v3-playlists') renderPlaylists();
else if (id === 'v3-saved') renderSaved();
});
}
function boot() { renderPlaylists(); renderSaved(); }
// `defer` runs this at readyState 'interactive' โ later scripts have not
// evaluated yet, so wait for DOMContentLoaded (see static/v3/index.html).
if (document.readyState !== 'complete') document.addEventListener('DOMContentLoaded', boot, { once: true });
else boot();
})();