feedBack/static/js/section-practice.js
Byron Gamatos d9fa6d3f55
refactor(highway): make the highway global explicit before the module flip (R3c) (#912)
73 bare `highway.x` references -> `window.highway.x`, across app.js and 10 other files.
Provably a NO-OP today. It is the precondition for flipping highway.js to a module.

━━━ WHY THIS HAS TO LAND FIRST ━━━

highway.js is a CLASSIC script. Its top-level `const highway = createHighway()` therefore
creates a GLOBAL LEXICAL BINDING — visible as a bare name to every other classic script AND
to every ES module. 73 call sites quietly rely on that.

The moment highway.js becomes a module, that binding is gone. `const` in a module is
module-scoped, not global. Every one of those 73 sites becomes a ReferenceError, and the
flip is impossible until they say what they mean.

`window.highway = highway` is already set, to the same object, on the same line. So this is
an identity rewrite — verified in the browser below.

━━━ THE REWRITE BIT ME THREE TIMES. REGEX IS NOT ENOUGH FOR THIS. ━━━

1. A SHADOWED LOCAL. capabilities/note-detection.js does `const highway = window.highway`.
   Its 9 bare uses are LOCAL and already correct; a blind rewrite would have emitted
   `const window.highway = window.highway`. Excluded.

2. HALF-CONVERTED GUARDS — the dangerous one. Six sites read
   `typeof highway !== 'undefined' && highway && typeof highway.setTime === 'function'`.
   The regex converted the CONSEQUENT and left the TEST, which is WORSE than not touching
   them: after the flip `typeof highway` is 'undefined', so each guard is PERMANENTLY FALSE
   and the code behind it silently never runs. transport.js's was the seek->setTime sync:
   the chart clock would have quietly desynced after every seek, with nothing failing.
   All six now test window.highway.

3. TWO MORE BARE REFERENCES, found by Codex [P2] and confirmed by an AST scan: app.js:3114
   and :3176 use `highway && typeof window.highway.getSections === 'function'`. My grep
   searched for `typeof highway`, not `highway &&`. After the flip these throw, the catch
   swallows it, and the editor silently falls back to a ±4s edit window and arrangement 0.

Regex missed a shadow, a half-conversion, and two bare reads. The final check is an
AST pass that resolves scopes and reports every `highway` identifier not bound locally.
It now reports ZERO.

VERIFIED. A/B against origin/main in two browsers, 15 probes, IDENTICAL, zero page errors:
window.highway is the same object as the bare global, the whole API surface resolves, a real
song plays, the chart clock advances, getPerf().drawMs > 0 — and `seek syncs chart` passes,
which is the exact guard I nearly broke in (2).

node 1045, pytest 2416, ESLint 0, Codex 0.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 11:56:07 +02:00

1217 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Section practice — the practice-a-section bar, its phrase parts, and the popover
// that drives them.
//
// THE FIRST SLICE OUT OF app.js's STRONGLY-CONNECTED CORE. It could not be cut by
// dependency closure: seeding a closure from section-practice, from loops, from
// count-in, or from the JUCE seek shim all return the SAME 178-function set, because
// setLoop() and practiceSection() call each other. So it is cut BY NAME, and
// everything it calls back into app.js goes through the host seam.
//
// It owns its own state — the _sectionPractice* / _sectionParents* scalars are read
// nowhere else and move in with it. It needs 11 hooks from app.js, and four of those
// are read-only GETTERS: loopA / loopB / _audioSeekGen / _loopMutationGen are only
// ever READ here, never written, so app.js keeps owning them and no state container
// is needed.
//
// app.js used to reach IN and reset this module's state directly (clearLoop() zeroed
// the selection; changeArrangement() invalidated the parent count). It cannot now — an
// imported binding is read-only — so those are exported as resetSelection() and
// invalidateParentCount(). That is strictly better: the module owns its own invariants
// instead of trusting two callers on the far side of the file to zero the right fields.
//
// ON THE SEAM: see ./host.js. Reading an unwired hook THROWS — there are no no-op
// defaults, because a host seam that can silently no-op is a trap. The plugin loader's
// seam had exactly that shape, and a dropped wiring line would have left the viz picker
// quietly not refreshing, with nothing to notice. tests/js/host_contract.test.js then
// fails CI if the hooks used here and the hooks wired in app.js ever drift apart — the
// layer that catches it on the paths a smoke test never runs.
import { audio } from './audio-el.js';
import { esc } from './dom.js';
import { _audioDuration, _audioTime, audioSeekGen } from './transport.js';
import { formatTime } from './format.js';
import { host } from './host.js';
export function _sectionPracticeBarContains(el) {
if (!el) return false;
const bar = document.getElementById('section-practice-bar');
return !!(bar && bar.contains(el));
}
// ── Section Practice Bar ────────────────────────────────────────────────
// One-click looping over song section markers (window.highway.getSections —
// same array as 3D highway bundle.sections / "Now / Up Next").
// Reuses setLoop() so manual A/B controls and saved loops stay canonical.
let _sectionPracticeRanges = [];
let _sectionPracticeSelected = -1;
let _sectionPracticeFollowParent = -1;
let _sectionPracticeDurSynced = false;
let _sectionPracticeLogged = false;
let _sectionPracticeHooked = false;
let _sectionPracticeRetryTimer = null;
let _sectionPracticeLastPlayableCount = 0;
let _sectionPracticePlayablePopulateRerendered = false;
// Last-rendered parent count, so the bar can re-render when the parent layout
// changes after the initial render — notably when the synthetic "Start" section
// appears as notes-before-the-first-marker stream in late.
let _sectionPracticeLastParentCount = -1;
// Start-time identity of the active parent, tracked so it can be remapped to the
// correct index when the parent layout shifts (a late "Start" prepend moves every
// real parent by one) instead of leaving the raw index pointing at the wrong one.
let _sectionPracticeActiveParentStart = NaN;
let _sectionPracticeMode = false;
let _sectionPracticeActiveParent = -1;
let _sectionPracticeWholeSection = false;
let _sectionPracticeSavedPartIndex = 0;
// Monotonic token to cancel stale practiceSection() retries: a newer click
// (or a song/arrangement change, which also bumps _audioSeekGen) supersedes
// any in-flight retry loop so it can't re-arm the wrong loop/count-in.
let _sectionPracticeRequestGen = 0;
// >0 while a practiceSection() request is awaiting its loop. While set,
// _syncSectionPracticeFromLoop() (e.g. from a mid-await bar re-render) must not
// reconcile against the half-applied / previous loop — practiceSection owns the
// section state and applies it once its own gen check passes.
let _sectionPracticeRequestInFlight = 0;
export function _setSectionPracticeMode(on, opts = {}) {
const next = !!on;
if (next === _sectionPracticeMode && !opts.force) return;
_sectionPracticeMode = next;
const cb = document.getElementById('section-practice-mode');
if (cb) cb.checked = _sectionPracticeMode;
// Surface the "looping" state on the collapsed pill so the user can tell
// Section Practice is armed without opening the popover.
const pill = document.getElementById('section-practice-pill');
if (pill) pill.classList.toggle('section-practice-pill--active', _sectionPracticeMode);
_sectionPracticeFollowParent = -1;
if (_sectionPracticeMode) {
if (opts.defaultWholeOn) {
_sectionPracticeWholeSection = true;
}
_updateSectionPracticeHighlight(_audioTime());
if (opts.defaultWholeOn) {
_syncSectionPracticePieceUi();
}
} else {
// Turning the feature off must cancel any in-flight practiceSection()
// retry: otherwise a stale setLoop() that lands after the user unchecks
// Section Practice would re-arm the loop, flip the mode back on via
// _syncSectionPracticeFromLoop(), and restart playback through
// startCountIn(). Bumping the request gen makes the pending retry bail.
_sectionPracticeRequestGen++;
// Cancel any pending count-in: every section-practice teardown routes
// through here (mode toggle off, clearLoop, and _hideSectionPracticeBar
// on song/arrangement change), so a countdown started by a prior section
// click must not resume playback after the user has turned practice off.
host._cancelCountIn();
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
_updateSectionPracticeHighlight(_audioTime());
if (!opts.skipClearLoop && (host.loopA() !== null || host.loopB() !== null)) {
host.clearLoop();
}
}
}
export function onSectionPracticeModeChange() {
const cb = document.getElementById('section-practice-mode');
if (!cb) return;
const turningOn = cb.checked && !_sectionPracticeMode;
_setSectionPracticeMode(cb.checked, { defaultWholeOn: turningOn });
}
export function _resetSectionPracticeLog() {
_sectionPracticeLogged = false;
_sectionPracticeLastPlayableCount = 0;
_sectionPracticePlayablePopulateRerendered = false;
}
function _sectionPracticeHighway() {
return window.highway || null;
}
function _sectionPracticeDuration() {
const d = _audioDuration();
if (d && Number.isFinite(d) && d > 0) return d;
const cd = window.feedBack?.currentSong?.duration;
return (cd && Number.isFinite(cd) && cd > 0) ? cd : 0;
}
export function _sectionPracticeSourceSections() {
const hw = _sectionPracticeHighway();
if (!hw || typeof hw.getSections !== 'function') return [];
const raw = hw.getSections();
return Array.isArray(raw) ? raw : [];
}
export function _sectionPracticeStartTime(s) {
const t = s.time ?? s.startTime ?? s.start_time ?? s.start;
const n = Number(t);
return Number.isFinite(n) ? n : NaN;
}
function _sectionPracticeBaseName(rawName, fallbackIndex) {
let s = (typeof rawName === 'string' ? rawName : '').trim();
if (!s) s = `Section ${fallbackIndex + 1}`;
// Normalise separators and strip common trailing digits like "Chorus 2"
s = s.replace(/_/g, ' ');
s = s.replace(/\s*\d+$/u, '');
const lower = s.toLowerCase();
const canonical = {
intro: 'Intro',
verse: 'Verse',
chorus: 'Chorus',
bridge: 'Bridge',
solo: 'Solo',
riff: 'Riff',
outro: 'Outro',
}[lower];
if (canonical) return canonical;
// Fallback: title-case words
return lower.split(/\s+/).filter(Boolean).map(w => w[0].toUpperCase() + w.slice(1)).join(' ') || `Section ${fallbackIndex + 1}`;
}
const _SECTION_PRACTICE_START_GAP_SEC = 0.05;
function _sectionPracticeNoteTime(note) {
const t = note?.t ?? note?.time ?? note?.start_time ?? note?.start;
const n = Number(t);
return Number.isFinite(n) ? n : NaN;
}
function _sectionPracticePlayableCount() {
const hw = _sectionPracticeHighway();
if (!hw) return 0;
let count = 0;
if (typeof hw.getNotes === 'function') {
const notes = hw.getNotes();
if (notes?.length) count += notes.length;
}
if (typeof hw.getChords === 'function') {
const chords = hw.getChords();
if (chords?.length) count += chords.length;
}
return count;
}
function _sectionPracticeHasNotesBefore(beforeTime) {
const hw = _sectionPracticeHighway();
if (!hw) return false;
const cutoff = Number(beforeTime);
if (!Number.isFinite(cutoff)) return false;
const sources = [];
if (typeof hw.getNotes === 'function') {
const notes = hw.getNotes();
if (notes?.length) sources.push(notes);
}
if (typeof hw.getChords === 'function') {
const chords = hw.getChords();
if (chords?.length) sources.push(chords);
}
for (let s = 0; s < sources.length; s++) {
const items = sources[s];
for (let i = 0; i < items.length; i++) {
const t = _sectionPracticeNoteTime(items[i]);
if (Number.isFinite(t) && t < cutoff) return true;
}
}
return false;
}
function _maybeRerenderSectionPracticeOnPlayableLoad() {
const count = _sectionPracticePlayableCount();
const prev = _sectionPracticeLastPlayableCount;
_sectionPracticeLastPlayableCount = count;
if (!_sectionPracticeSourceSections().length || !_sectionPracticeBarIsReady()) return;
// Re-render whenever the parent layout changes after the bar is up — the
// synthetic "Start" section can appear (±1 parent) once a note before the
// first marker streams in, which would otherwise leave the DOM chip indices
// out of sync with _buildSectionParents() (clicks/highlights hitting the
// wrong section). _buildSectionParents() is memoized, so this is cheap.
const parents = _buildSectionParents();
const parentCount = parents.length;
if (parentCount !== _sectionPracticeLastParentCount) {
// Remap the active parent by start-time identity before re-rendering: a
// late "Start" prepend shifts every real parent's index, so the raw
// index would otherwise point at the wrong section (mis-highlighting and
// breaking whole/prev/next). Selected/part indices are within-parent and
// unaffected. Skip when no active parent or no prior snapshot.
if (_sectionPracticeActiveParent >= 0 && Number.isFinite(_sectionPracticeActiveParentStart)) {
const remapped = parents.findIndex(
(p) => Math.abs(p.start - _sectionPracticeActiveParentStart) < 0.001,
);
if (remapped >= 0) _sectionPracticeActiveParent = remapped;
}
_sectionPracticeLastParentCount = parentCount;
renderSectionPracticeBar();
_sectionPracticeActiveParentStart =
(_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent])
? parents[_sectionPracticeActiveParent].start : NaN;
return;
}
// Keep the active-parent start snapshot fresh while the layout is stable, so
// it holds the correct pre-change value when the layout next shifts.
_sectionPracticeActiveParentStart =
(_sectionPracticeActiveParent >= 0 && parents[_sectionPracticeActiveParent])
? parents[_sectionPracticeActiveParent].start : NaN;
if (_sectionPracticePlayablePopulateRerendered) return;
if (prev !== 0 || count === 0) return;
_sectionPracticePlayablePopulateRerendered = true;
renderSectionPracticeBar();
}
// _buildSectionParents() runs on the 60 Hz highlight path, so memoize it.
// The parent layout is a pure function of the highway's section list (a
// stable array reference per song), the song duration, and whether any
// notes/chords precede the first marker (the synthetic "Start" section).
// That last input can flip while WS note chunks are still streaming in, so
// the note/chord counts are part of the key; once a song is fully loaded
// all four inputs stabilize and the per-frame call becomes a cache hit.
// Every call site uses the result read-only, so returning the cached array
// reference is safe.
let _sectionParentsCache = null;
let _sectionParentsCacheRaw = null;
let _sectionParentsCacheDur = -1;
let _sectionParentsCacheNoteLen = -1;
let _sectionParentsCacheChordLen = -1;
export function _buildSectionParents() {
const raw = _sectionPracticeSourceSections();
if (!raw.length) return [];
const dur = _sectionPracticeDuration();
const hw = _sectionPracticeHighway();
const noteLen = (hw && typeof hw.getNotes === 'function' && hw.getNotes()?.length) || 0;
const chordLen = (hw && typeof hw.getChords === 'function' && hw.getChords()?.length) || 0;
if (_sectionParentsCache !== null
&& _sectionParentsCacheRaw === raw
&& _sectionParentsCacheDur === dur
&& _sectionParentsCacheNoteLen === noteLen
&& _sectionParentsCacheChordLen === chordLen) {
return _sectionParentsCache;
}
const sorted = [...raw].sort((a, b) => _sectionPracticeStartTime(a) - _sectionPracticeStartTime(b));
// Step 1: collapse consecutive same-name markers into logical groups.
const groups = [];
for (let i = 0; i < sorted.length; i++) {
const start = _sectionPracticeStartTime(sorted[i]);
if (!Number.isFinite(start)) continue;
const baseName = _sectionPracticeBaseName(sorted[i].name, groups.length);
const prev = groups[groups.length - 1];
if (prev && prev.baseName === baseName) {
prev.lastIndex = i;
} else {
groups.push({ baseName, firstIndex: i, lastIndex: i });
}
}
if (!groups.length) return [];
// Step 2: assign musician-friendly labels with counters (Verse 1, Verse 2, …).
const counters = Object.create(null);
const ranges = [];
for (let gi = 0; gi < groups.length; gi++) {
const g = groups[gi];
const base = g.baseName;
const count = (counters[base] || 0) + 1;
counters[base] = count;
const label = `${base} ${count}`;
const firstSec = sorted[g.firstIndex];
const start = _sectionPracticeStartTime(firstSec);
if (!Number.isFinite(start)) continue;
let end;
if (gi + 1 < groups.length) {
const nextFirst = sorted[groups[gi + 1].firstIndex];
end = _sectionPracticeStartTime(nextFirst);
} else {
end = dur;
}
if (!Number.isFinite(end) || end <= start) {
end = dur > start ? dur : start + 4;
}
ranges.push({ name: label, start, end });
}
if (ranges.length > 0) {
const firstStart = Number(ranges[0].start);
if (Number.isFinite(firstStart) && firstStart > _SECTION_PRACTICE_START_GAP_SEC
&& _sectionPracticeHasNotesBefore(firstStart)) {
ranges.unshift({ name: 'Start', start: 0, end: firstStart });
}
}
_sectionParentsCache = ranges;
_sectionParentsCacheRaw = raw;
_sectionParentsCacheDur = dur;
_sectionParentsCacheNoteLen = noteLen;
_sectionParentsCacheChordLen = chordLen;
return ranges;
}
function _sectionPracticeResetSelectionUi() {
_sectionPracticeActiveParent = -1;
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
_sectionPracticeRanges = [];
}
function _sectionPracticeSourcePhrases() {
const hw = _sectionPracticeHighway();
if (!hw || typeof hw.getPracticePhrases !== 'function') return null;
const raw = hw.getPracticePhrases();
return (raw && raw.length) ? raw : null;
}
function _buildPhrasePartsForParent(parent) {
if (!parent) return [];
const dur = _sectionPracticeDuration();
const windowStart = parent.start;
const windowEnd = parent.end;
const phrases = _sectionPracticeSourcePhrases();
const parts = [];
if (phrases) {
const inWindow = phrases.filter(
(ph) => ph.start_time >= windowStart - 0.001 && ph.start_time < windowEnd - 0.001,
);
if (inWindow.length) {
for (let i = 0; i < inWindow.length; i++) {
const ph = inWindow[i];
let start = ph.start_time;
let end = ph.end_time;
if (!Number.isFinite(end) || end > windowEnd) end = windowEnd;
if (!Number.isFinite(start) || end <= start) continue;
if (dur && Number.isFinite(dur) && end > dur) end = dur;
parts.push({ name: parent.name, start, end });
}
// Snap first part to section start so the loop aligns with the selected marker
// when the first in-window phrase iteration begins later (e.g. Chorus 2).
if (parts.length > 0 && parts[0].start > windowStart) {
parts[0].start = windowStart;
}
return parts;
}
}
let start = windowStart;
let end = windowEnd;
if (dur && Number.isFinite(dur) && end > dur) end = dur;
if (Number.isFinite(start) && Number.isFinite(end) && end > start) {
parts.push({ name: parent.name, start, end });
}
return parts;
}
function _buildSectionPracticeRanges() {
if (_sectionPracticeActiveParent < 0) return [];
const parents = _buildSectionParents();
const parent = parents[_sectionPracticeActiveParent];
if (!parent) return [];
return _buildPhrasePartsForParent(parent);
}
function _sectionPracticeActiveParentRange() {
if (_sectionPracticeActiveParent < 0) return null;
const parents = _buildSectionParents();
const parent = parents[_sectionPracticeActiveParent];
if (!parent) return null;
const dur = _sectionPracticeDuration();
let end = Number(parent.end);
const start = Number(parent.start);
if (dur && Number.isFinite(dur) && end > dur) end = dur;
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return null;
return { name: parent.name, start, end };
}
function _sectionPracticeResolveLoopTarget(index, opts = {}) {
if (opts.whole) {
return _sectionPracticeActiveParentRange();
}
return _sectionPracticeRanges[index] ?? null;
}
function _formatSectionPracticeName(name) {
return name.replace(/_/g, ' ');
}
const _SECTION_PRACTICE_CHIP_KINDS = new Set([
'intro', 'verse', 'chorus', 'bridge', 'solo', 'riff', 'outro',
]);
function _sectionPracticeChipKindClass(name, index) {
const base = _sectionPracticeBaseName(name, index);
const kind = base.toLowerCase();
if (!_SECTION_PRACTICE_CHIP_KINDS.has(kind)) return '';
return ` section-practice-chip--${kind}`;
}
function _sectionPracticeWholeCheckboxHtml() {
return '<label class="section-practice-whole-wrap" title="Loop the whole selected section">'
+ '<input type="checkbox" id="section-practice-whole" onchange="onSectionPracticeWholeChange()">'
+ '<span class="section-practice-whole-text">Full section</span>'
+ '</label>';
}
function _sectionPracticePieceRowHtml() {
return '<div id="section-practice-piece-row" class="section-practice-row section-practice-piece-row">'
+ '<span id="section-practice-piece-label" class="section-practice-piece-label" aria-live="polite">Part — of —</span>'
+ '<button type="button" id="section-practice-piece-prev" class="section-practice-chip" onclick="onPhrasePrev()">◀ Previous</button>'
+ '<button type="button" id="section-practice-piece-next" class="section-practice-chip" onclick="onPhraseNext()">Next ▶</button>'
+ '</div>';
}
function _sectionPracticeMainRow() {
const bar = document.getElementById('section-practice-bar');
if (!bar) return null;
return bar.querySelector('.section-practice-controls-row')
|| bar.querySelector('.section-practice-primary-row')
|| bar.querySelector('.section-practice-row:not(.section-practice-piece-row):not(.section-practice-chips-row)');
}
function _migrateSectionPracticeDomLayout(bar) {
if (!bar || bar.querySelector('.section-practice-controls-row')) return;
const pieceRow = document.getElementById('section-practice-piece-row');
const scroll = document.getElementById('section-practice-scroll');
const modeWrap = bar.querySelector('.section-practice-mode-wrap');
const wholeWrap = bar.querySelector('.section-practice-whole-wrap');
let label = bar.querySelector('.section-practice-label');
const controlsRow = document.createElement('div');
controlsRow.className = 'section-practice-row section-practice-controls-row';
if (modeWrap) controlsRow.appendChild(modeWrap);
if (wholeWrap) controlsRow.appendChild(wholeWrap);
if (pieceRow) controlsRow.appendChild(pieceRow);
const chipsRow = document.createElement('div');
chipsRow.className = 'section-practice-row section-practice-chips-row';
if (label) {
chipsRow.appendChild(label);
} else {
label = document.createElement('span');
label.className = 'section-practice-label';
label.textContent = 'Sections:';
chipsRow.appendChild(label);
}
if (scroll) chipsRow.appendChild(scroll);
bar.replaceChildren(controlsRow, chipsRow);
}
function _sectionPracticeBarInnerHtml() {
return '<div class="section-practice-row section-practice-controls-row">'
+ '<label class="section-practice-mode-wrap" title="Loop the selected section until turned off">'
+ '<input type="checkbox" id="section-practice-mode" onchange="onSectionPracticeModeChange()">'
+ '<span class="section-practice-mode-text">Practice Section</span>'
+ '</label>'
+ _sectionPracticeWholeCheckboxHtml()
+ _sectionPracticePieceRowHtml()
+ '</div>'
+ '<div class="section-practice-row section-practice-chips-row">'
+ '<span class="section-practice-label">Sections:</span>'
+ '<div id="section-practice-scroll" class="section-practice-scroll" role="toolbar"></div>'
+ '</div>';
}
function _ensureSectionPracticeWholeCheckbox() {
const existing = document.getElementById('section-practice-whole');
const mainRow = _sectionPracticeMainRow();
if (!mainRow) return;
if (existing) {
const wrap = existing.closest('.section-practice-whole-wrap');
if (wrap && !mainRow.contains(wrap)) {
const modeWrap = mainRow.querySelector('.section-practice-mode-wrap');
if (modeWrap) modeWrap.insertAdjacentElement('afterend', wrap);
else mainRow.insertBefore(wrap, mainRow.firstChild);
}
return;
}
const modeWrap = mainRow.querySelector('.section-practice-mode-wrap');
if (modeWrap) {
modeWrap.insertAdjacentHTML('afterend', _sectionPracticeWholeCheckboxHtml());
} else {
mainRow.insertAdjacentHTML('afterbegin', _sectionPracticeWholeCheckboxHtml());
}
}
function _sectionPracticeCurrentPartIndex() {
const total = _sectionPracticeRanges.length;
if (!total) return 0;
if (!_sectionPracticeWholeSection && _sectionPracticeSelected >= 0) {
return Math.min(_sectionPracticeSelected, total - 1);
}
if (_sectionPracticeSavedPartIndex >= 0) {
return Math.min(_sectionPracticeSavedPartIndex, total - 1);
}
return 0;
}
function _sectionPracticePillHtml() {
return '<button type="button" id="section-practice-pill" class="section-practice-pill"'
+ ' aria-haspopup="dialog" aria-expanded="false" aria-controls="section-practice-bar"'
+ ' aria-label="Section practice"'
+ ' onclick="toggleSectionPracticePopover()" title="Section practice">'
+ '<span class="section-practice-pill-icon" aria-hidden="true">'
+ '<svg class="v3-rail-svg section-practice-pill-svg" viewBox="0 0 24 24">'
+ '<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8M12,10A2,2 0 0,0 10,12A2,2 0 0,0 12,14A2,2 0 0,0 14,12A2,2 0 0,0 12,10Z"/>'
+ '</svg></span>'
+ '<span class="section-practice-pill-text">Practice</span>'
+ '<span class="section-practice-pill-caret" aria-hidden="true">▾</span>'
+ '</button>';
}
function _syncSectionPracticePillV3Chrome(isV3) {
const pill = document.getElementById('section-practice-pill');
if (!pill) return;
pill.classList.toggle('v3-rail-icon', isV3);
let ring = pill.querySelector('.v3-rail-border');
if (isV3) {
if (!ring) {
ring = document.createElement('span');
ring.className = 'v3-rail-border';
ring.setAttribute('aria-hidden', 'true');
pill.insertBefore(ring, pill.firstChild);
}
pill.setAttribute('title', 'Practice');
pill.setAttribute('aria-label', 'Practice');
} else {
if (ring) ring.remove();
pill.setAttribute('title', 'Section practice');
pill.setAttribute('aria-label', 'Section practice');
}
}
// Wrap an existing #section-practice-bar in the pill control (creating the
// wrapper + pill if missing). Defensive: works whether the bar came from the
// static markup (already wrapped) or a chrome whose index.html predates the
// pill (e.g. a not-yet-rebased v3 build) — the bar is always reachable as a
// closed popover behind the pill afterward.
function _ensureSectionPracticeControlWrap(bar) {
if (!bar) return null;
let ctrl = (bar.closest && bar.closest('.section-practice-control'))
|| document.getElementById('section-practice-control');
if (ctrl) {
if (!ctrl.contains(bar)) ctrl.appendChild(bar);
} else {
ctrl = document.createElement('div');
ctrl.id = 'section-practice-control';
ctrl.className = 'section-practice-control section-practice-control--hidden';
if (bar.parentNode) bar.parentNode.insertBefore(ctrl, bar);
ctrl.appendChild(bar);
}
if (!ctrl.querySelector('#section-practice-pill')) {
ctrl.insertAdjacentHTML('afterbegin', _sectionPracticePillHtml());
}
// Popover visibility is driven by --open now; clear any legacy hidden class.
bar.classList.remove('section-practice-bar--hidden');
_mountSectionPracticeControlSafe(ctrl);
return ctrl;
}
// Mount the pill control so its popover — whose chip <button>s would otherwise
// be matched by a plugin's `#player-controls > button:last-child` injector
// anchor and throw on insertBefore — lives OUTSIDE #player-controls. Prefers
// #player-footer; otherwise inserts as a sibling immediately before
// #player-controls. Idempotent and never throws on layout variants where
// #player-controls isn't a child of #player-footer (it checks parentage before
// using insertBefore). The v3 rail mount in _placeSectionPracticeControlForChrome
// supersedes this, so a control already in the rail is left alone.
function _mountSectionPracticeControlSafe(ctrl) {
if (!ctrl) return;
if (ctrl.closest && ctrl.closest('#v3-player-rail')) return;
const controls = document.getElementById('player-controls');
const footer = document.getElementById('player-footer');
if (footer) {
if (controls && controls.parentNode === footer) {
if (ctrl.nextSibling !== controls) footer.insertBefore(ctrl, controls);
} else if (ctrl.parentNode !== footer) {
footer.appendChild(ctrl);
}
return;
}
if (controls && controls.parentNode) {
if (ctrl.parentNode !== controls.parentNode || ctrl.nextSibling !== controls) {
controls.parentNode.insertBefore(ctrl, controls);
}
return;
}
// No footer and #player-controls has no parent (degenerate/detached layout):
// never nest the popover INSIDE #player-controls — that re-arms the injector
// bug. Fall back to the player container so the chip buttons stay outside it.
const player = document.getElementById('player');
if (player && ctrl.parentNode !== player) player.appendChild(ctrl);
}
// In the v3 chrome the pill becomes a left-rail icon (CSS hides its label and
// opens the popover to the right). app.js owns the toggle/dismiss, so this is
// independent of player-chrome.js's own rail-popover wiring.
// Idempotent + reversible: mounts the control into #v3-player-rail under v3,
// or back out to the footer under v2. Safe to call every frame — it only
// touches the DOM when the placement is actually wrong, so a chrome that flips
// uiVersion (or mounts #v3-player-rail) after the bar is "ready" still gets the
// pill relocated on the next draw tick. See the draw hook's ready path.
export function _placeSectionPracticeControlForChrome() {
const ctrl = document.getElementById('section-practice-control');
if (!ctrl) return;
const isV3 = !!(window.feedBack && window.feedBack.uiVersion === 'v3');
ctrl.classList.toggle('section-practice-control--v3', isV3);
_syncSectionPracticePillV3Chrome(isV3);
if (isV3) {
const rail = document.getElementById('v3-player-rail');
if (rail) {
const dot = rail.querySelector('.v3-rail-dot');
if (dot) {
// Reorder even when already in the rail but after the dot (or the
// dot mounted later) so the placement self-corrects each tick.
if (ctrl.parentElement !== rail || ctrl.nextElementSibling !== dot) {
rail.insertBefore(ctrl, dot);
}
} else if (ctrl.parentElement !== rail) {
rail.appendChild(ctrl);
}
}
} else if (ctrl.closest && ctrl.closest('#v3-player-rail')) {
// Chrome reverted to v2: pull the control out of the rail (detach first
// so _mountSectionPracticeControlSafe's rail guard doesn't no-op) and
// re-home it in the footer.
ctrl.remove();
_mountSectionPracticeControlSafe(ctrl);
}
}
function _ensureSectionPracticeDom() {
let bar = document.getElementById('section-practice-bar');
if (bar) {
_ensureSectionPracticeControlWrap(bar);
_migrateSectionPracticeDomLayout(bar);
if (!bar.querySelector('#section-practice-piece-row')) {
const controlsRow = bar.querySelector('.section-practice-controls-row')
|| bar.querySelector('.section-practice-primary-row');
if (controlsRow) {
controlsRow.insertAdjacentHTML('beforeend', _sectionPracticePieceRowHtml());
} else {
bar.insertAdjacentHTML('beforeend', _sectionPracticePieceRowHtml());
}
}
_ensureSectionPracticeWholeCheckbox();
bar.querySelector('.section-practice-show-all-wrap')?.remove();
_placeSectionPracticeControlForChrome();
return bar;
}
const controls = document.getElementById('player-controls');
const footer = document.getElementById('player-footer');
if (!footer && !controls) return null;
bar = document.createElement('div');
bar.id = 'section-practice-bar';
bar.className = 'section-practice-bar';
bar.setAttribute('role', 'dialog');
bar.setAttribute('aria-label', 'Section practice');
bar.innerHTML = _sectionPracticeBarInnerHtml();
const ctrl = document.createElement('div');
ctrl.id = 'section-practice-control';
ctrl.className = 'section-practice-control section-practice-control--hidden';
ctrl.innerHTML = _sectionPracticePillHtml();
ctrl.appendChild(bar);
// Mount OUTSIDE #player-controls (in #player-footer, or as its sibling) so
// the popover's chip <button>s can't be matched by a plugin injector that
// anchors on `#player-controls > button:last-of-type` (see static/v3/index.html).
_mountSectionPracticeControlSafe(ctrl);
_placeSectionPracticeControlForChrome();
return bar;
}
// "Show" = make the pill available. The bar itself stays a CLOSED popover
// until the user opens it via the pill (toggleSectionPracticePopover).
function _showSectionPracticeBar(bar) {
const ctrl = (bar && bar.closest && bar.closest('.section-practice-control'))
|| document.getElementById('section-practice-control');
if (ctrl) ctrl.classList.remove('section-practice-control--hidden');
}
export function _sectionPracticePopoverOpen() {
const bar = document.getElementById('section-practice-bar');
return !!(bar && bar.classList.contains('section-practice-bar--open'));
}
function _openSectionPracticePopover() {
const bar = document.getElementById('section-practice-bar');
if (!bar) return;
bar.classList.add('section-practice-bar--open');
const pill = document.getElementById('section-practice-pill');
if (pill) pill.setAttribute('aria-expanded', 'true');
_installSectionPracticeDismiss();
}
function _closeSectionPracticePopover() {
const bar = document.getElementById('section-practice-bar');
const pill = document.getElementById('section-practice-pill');
if (bar) {
const focusWasInside = bar.contains(document.activeElement);
bar.classList.remove('section-practice-bar--open');
// Return focus to the pill if it was inside the popover — otherwise it
// would be stranded on a now-display:none control, which also makes the
// shortcut gate treat that stale target as interactive and suppress
// player keys until focus is moved manually.
if (focusWasInside && pill) pill.focus();
}
if (pill) pill.setAttribute('aria-expanded', 'false');
}
export function toggleSectionPracticePopover() {
if (_sectionPracticePopoverOpen()) _closeSectionPracticePopover();
else _openSectionPracticePopover();
}
let _sectionPracticeDismissBound = false;
function _installSectionPracticeDismiss() {
if (_sectionPracticeDismissBound) return;
_sectionPracticeDismissBound = true;
// Click-outside + Esc close. Bound once on document; the pill's own click is
// inside #section-practice-control so it never self-closes. Listeners added
// mid-dispatch don't fire for the opening click, so there's no immediate
// close race.
//
// The click listener uses the CAPTURE phase: the v3 player rail's icon
// buttons call e.stopPropagation() in their click handler (player-chrome.js
// wireRail), which kills bubbling before it reaches document. A bubble-phase
// outside-click dismiss would therefore never fire when the user clicks a
// rail icon (Plugins, Audio, …) to open another popover, leaving this
// popover stranded open on top of it. Capture runs before the target's
// handler, so the stopPropagation can't swallow it. This mirrors the audio
// mixer popover (audio-mixer.js), which dismisses outside-clicks the same
// way. (Esc stays bubble-phase — no rail handler stops keydown propagation,
// so it already reaches us, and capturing it would reorder it ahead of the
// player's Escape-to-exit handling.)
document.addEventListener('click', (e) => {
if (!_sectionPracticePopoverOpen()) return;
const ctrl = document.getElementById('section-practice-control');
if (ctrl && ctrl.contains(e.target)) return;
_closeSectionPracticePopover();
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && _sectionPracticePopoverOpen()) _closeSectionPracticePopover();
});
}
export function _hideSectionPracticeBar() {
_setSectionPracticeMode(false, { skipClearLoop: true });
_closeSectionPracticePopover();
const ctrl = document.getElementById('section-practice-control');
if (ctrl) {
// Move focus out before hiding the control: a display:none element that
// still holds focus leaves document.activeElement on it, which the
// shortcut gate (_shortcutDispatchBlocked) would treat as an interactive
// target and swallow player keys. Covers the pill (where the close above
// may have just parked focus) and any bar descendant.
const ae = document.activeElement;
if (ae && ctrl.contains(ae) && typeof ae.blur === 'function') ae.blur();
ctrl.classList.add('section-practice-control--hidden');
}
_sectionPracticeRanges = [];
_sectionPracticeActiveParent = -1;
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
_sectionPracticeFollowParent = -1;
_sectionPracticeDurSynced = false;
const scroll = document.getElementById('section-practice-scroll');
if (scroll) scroll.innerHTML = '';
_syncSectionPracticePieceUi();
}
export function _sectionPracticeBarIsReady() {
// "Ready" = the pill is available (sections exist) and the popover is
// populated. Independent of whether the popover is currently open, so the
// draw-hook retry loop settles even while the bar stays collapsed.
const ctrl = document.getElementById('section-practice-control');
if (!ctrl || ctrl.classList.contains('section-practice-control--hidden')) return false;
const scroll = document.getElementById('section-practice-scroll');
return !!(scroll && scroll.querySelector('[data-parent-idx]'));
}
export function _installSectionPracticeDrawHook() {
if (_sectionPracticeHooked) return;
const hw = _sectionPracticeHighway();
if (!hw || typeof hw.addDrawHook !== 'function') return;
_sectionPracticeHooked = true;
hw.addDrawHook(() => {
if (_sectionPracticeSourceSections().length === 0) return;
_maybeRerenderSectionPracticeOnPlayableLoad();
if (_sectionPracticeBarIsReady()) { _placeSectionPracticeControlForChrome(); return; }
renderSectionPracticeBar();
});
}
export function _scheduleSectionPracticeRetries() {
if (_sectionPracticeRetryTimer) clearTimeout(_sectionPracticeRetryTimer);
const delays = [0, 50, 200, 500, 1200];
let i = 0;
const tick = () => {
renderSectionPracticeBar();
i += 1;
if (i < delays.length && !_sectionPracticeBarIsReady()) {
_sectionPracticeRetryTimer = setTimeout(tick, delays[i]);
} else {
_sectionPracticeRetryTimer = null;
}
};
tick();
}
function _syncSectionPracticePieceUi() {
const label = document.getElementById('section-practice-piece-label');
const prev = document.getElementById('section-practice-piece-prev');
const next = document.getElementById('section-practice-piece-next');
const wholeCb = document.getElementById('section-practice-whole');
const total = _sectionPracticeRanges.length;
const active = _sectionPracticeActiveParent >= 0;
if (label) {
if (!active || !total) {
label.textContent = 'Part — of —';
} else {
const idx = _sectionPracticeCurrentPartIndex();
label.textContent = `Part ${idx + 1} of ${total}`;
}
}
if (wholeCb) {
wholeCb.checked = _sectionPracticeWholeSection;
}
const partIdx = (!active || !total || _sectionPracticeWholeSection)
? 0
: (_sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0);
if (prev) {
prev.disabled = !active || !total || (!_sectionPracticeWholeSection && partIdx <= 0);
}
if (next) {
next.disabled = !active || !total || (!_sectionPracticeWholeSection && partIdx >= total - 1);
}
}
export function renderSectionPracticeBar() {
_installSectionPracticeDrawHook();
const raw = _sectionPracticeSourceSections();
if (!_sectionPracticeLogged) {
_sectionPracticeLogged = true;
}
const parents = _buildSectionParents();
const bar = _ensureSectionPracticeDom();
const scroll = document.getElementById('section-practice-scroll');
if (!bar || !scroll) return;
if (!parents.length) {
_hideSectionPracticeBar();
return;
}
if (_sectionPracticeActiveParent >= parents.length) {
_sectionPracticeResetSelectionUi();
}
_showSectionPracticeBar(bar);
scroll.innerHTML = parents.map((p, i) => {
const label = _formatSectionPracticeName(p.name);
const tip = `${label} (${formatTime(p.start)}${formatTime(p.end)})`;
const kindClass = _sectionPracticeChipKindClass(p.name, i);
return `<button type="button" class="section-practice-chip${kindClass}" data-parent-idx="${i}" title="${esc(tip)}" onclick="onSectionParentClick(${i})">${esc(label)}</button>`;
}).join('');
_sectionPracticeRanges = _buildSectionPracticeRanges();
// Reconcile any active A-B loop with the (re)rendered section bar. Called
// unconditionally so a loop that arrived before the section markers — e.g.
// a Saved Loop or window.feedBack.setLoop() during song load, when no
// parent was active yet — still re-selects its chip once markers appear.
// _syncSectionPracticeFromLoop() scans all parents, so it can activate the
// matching one; run it before the piece UI so that reflects the result.
_syncSectionPracticeFromLoop();
_syncSectionPracticePieceUi();
_updateSectionPracticeHighlight(_audioTime());
}
export async function onSectionParentClick(parentIdx) {
const parents = _buildSectionParents();
const idx = Number(parentIdx);
if (!Number.isFinite(idx) || idx < 0 || idx >= parents.length) return;
_sectionPracticeActiveParent = idx;
_sectionPracticeRanges = _buildSectionPracticeRanges();
_sectionPracticeSelected = -1;
_sectionPracticeSavedPartIndex = 0;
_sectionPracticeWholeSection = true;
_syncSectionPracticePieceUi();
_updateSectionPracticeHighlight(_audioTime());
if (_sectionPracticeActiveParentRange() || _sectionPracticeRanges.length) {
await practiceSection(0, { whole: true });
}
}
export async function onSectionPracticeWholeChange() {
const cb = document.getElementById('section-practice-whole');
if (!cb || _sectionPracticeActiveParent < 0) return;
const total = _sectionPracticeRanges.length;
if (!total) return;
if (cb.checked === _sectionPracticeWholeSection) return;
_sectionPracticeWholeSection = cb.checked;
if (cb.checked) {
await practiceSection(_sectionPracticeCurrentPartIndex(), { whole: true });
return;
}
await practiceSection(0);
}
export async function onPhrasePrev() {
const total = _sectionPracticeRanges.length;
if (!total || _sectionPracticeActiveParent < 0) return;
if (_sectionPracticeWholeSection) {
_sectionPracticeWholeSection = false;
_syncSectionPracticePieceUi();
await practiceSection(0);
return;
}
const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0;
if (cur <= 0) return;
await practiceSection(cur - 1);
}
export async function onPhraseNext() {
const total = _sectionPracticeRanges.length;
if (!total || _sectionPracticeActiveParent < 0) return;
if (_sectionPracticeWholeSection) {
_sectionPracticeWholeSection = false;
_syncSectionPracticePieceUi();
await practiceSection(0);
return;
}
const cur = _sectionPracticeSelected >= 0 ? _sectionPracticeSelected : 0;
if (cur >= total - 1) return;
await practiceSection(cur + 1);
}
// Find which section parent / phrase part the active A-B loop corresponds to.
// Scans ALL parents (not just the active one) so a loop arriving from Saved
// Loops or window.feedBack.setLoop() can re-select the right chip even when
// its parent isn't the currently-active one. Returns { parentIdx, whole } or
// { parentIdx, whole:false, index } (the matching phrase part), or null.
function _sectionPracticeLoopMatch() {
if (host.loopA() === null || host.loopB() === null) return null;
const parents = _buildSectionParents();
for (let parentIdx = 0; parentIdx < parents.length; parentIdx++) {
const parent = parents[parentIdx];
let partMatch = -1;
const parts = _buildPhrasePartsForParent(parent);
for (let i = 0; i < parts.length; i++) {
if (Math.abs(parts[i].start - host.loopA()) < 0.05 && Math.abs(parts[i].end - host.loopB()) < 0.05) {
partMatch = i;
break;
}
}
const wholeMatch = Math.abs(parent.start - host.loopA()) < 0.05 && Math.abs(parent.end - host.loopB()) < 0.05;
if (wholeMatch && partMatch >= 0) {
// A single-part section's part range coincides with the whole
// section. Preserve the user's whole/part intent when this is the
// already-active parent; otherwise default to whole-section.
if (parentIdx === _sectionPracticeActiveParent && !_sectionPracticeWholeSection) {
return { parentIdx, whole: false, index: partMatch };
}
return { parentIdx, whole: true };
}
if (wholeMatch) return { parentIdx, whole: true };
if (partMatch >= 0) return { parentIdx, whole: false, index: partMatch };
}
return null;
}
function _blurSectionPracticeFocusIfNeeded() {
const ae = document.activeElement;
const bar = document.getElementById('section-practice-bar');
if (ae && bar && bar.contains(ae) && typeof ae.blur === 'function') {
ae.blur();
}
}
export async function practiceSection(index, opts = {}) {
const requestGen = ++_sectionPracticeRequestGen;
const seekGen = audioSeekGen();
const loopGen = host._loopMutationGen();
const whole = !!opts.whole;
const r = _sectionPracticeResolveLoopTarget(index, opts);
if (!r) return;
const dur = _sectionPracticeDuration();
const start = Number(r.start);
let end = Number(r.end);
if (dur && Number.isFinite(dur) && end > dur) end = dur;
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) return;
// Mark the request in-flight so a bar re-render that fires during the awaited
// setLoop below doesn't reconcile section state against the old/half-applied
// loop. Cleared in finally so every exit path (bail, success, failure) resets.
_sectionPracticeRequestInFlight++;
try {
host._cancelCountIn();
_setSectionPracticeMode(true, { skipClearLoop: true });
// setLoop() is seek-gated: it returns false when the seek is cancelled
// during arrangement switches / teardown-gen bumps, or when the backend
// clock clamps off-target. Retry briefly to land after the transport
// becomes ready without forking the loop system.
let ok = false;
for (let attempt = 0; attempt < 5; attempt++) {
// A newer click or a song/arrangement change supersedes this retry.
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
try {
// skipSectionSync: this function owns the section-practice state and
// applies it below under the request-gen guard, so a stale retry
// landing here can't re-sync/re-arm via setLoop's shared path.
// commitGuard: also prevent a superseded retry from committing
// loopA/loopB at all — setLoop re-checks this right before arming,
// after its internal seek await, so a stale loop is never armed.
ok = await host.setLoop(start, end, {
skipSectionSync: true,
commitGuard: () => requestGen === _sectionPracticeRequestGen && seekGen === audioSeekGen() && loopGen === host._loopMutationGen(),
});
} catch (err) {
ok = false;
}
if (ok) break;
await new Promise(res => setTimeout(res, 60 + attempt * 90));
}
// Re-check after the awaited retries before applying any loop/count-in state.
if (requestGen !== _sectionPracticeRequestGen || seekGen !== audioSeekGen() || loopGen !== host._loopMutationGen()) return;
if (ok) {
_sectionPracticeWholeSection = whole;
if (!whole) {
_sectionPracticeSelected = index;
_sectionPracticeSavedPartIndex = index;
}
_blurSectionPracticeFocusIfNeeded();
_updateSectionPracticeHighlight(_audioTime());
host.startCountIn({ immediate: true });
} else {
_setSectionPracticeMode(false, { skipClearLoop: true });
}
} finally {
_sectionPracticeRequestInFlight--;
}
}
export function _syncSectionPracticeFromLoop() {
// A practiceSection() request owns the section state while it awaits its
// loop; reconciling here against the prior/half-applied loop would fight it
// (snapping the active parent back or toggling the mode off mid-request).
if (_sectionPracticeRequestInFlight > 0) return;
if (!_buildSectionParents().length) return;
const match = _sectionPracticeLoopMatch();
if (match) {
// The loop may belong to a parent that isn't currently active (e.g.
// restored from Saved Loops); switch to it and rebuild its parts so
// the part-level UI reflects the matched section.
if (match.parentIdx !== _sectionPracticeActiveParent) {
_sectionPracticeActiveParent = match.parentIdx;
_sectionPracticeRanges = _buildSectionPracticeRanges();
}
_sectionPracticeWholeSection = match.whole;
if (!match.whole) {
_sectionPracticeSelected = match.index;
_sectionPracticeSavedPartIndex = match.index;
} else {
_sectionPracticeSelected = -1;
}
} else {
_sectionPracticeWholeSection = false;
_sectionPracticeSelected = -1;
}
if (host.loopA() !== null && host.loopB() !== null) {
if (match) {
if (!_sectionPracticeMode) {
_setSectionPracticeMode(true, { skipClearLoop: true });
}
} else if (_sectionPracticeMode) {
_setSectionPracticeMode(false, { skipClearLoop: true });
}
} else if (_sectionPracticeMode) {
_setSectionPracticeMode(false, { skipClearLoop: true });
}
_updateSectionPracticeHighlight(_audioTime());
}
function _sectionPracticeIndexAtTime(t) {
if (!Number.isFinite(t) || _sectionPracticeRanges.length === 0) return -1;
for (let i = _sectionPracticeRanges.length - 1; i >= 0; i--) {
if (t >= _sectionPracticeRanges[i].start) return i;
}
return -1;
}
function _sectionPracticeParentIndexAtTime(t) {
const parents = _buildSectionParents();
if (!Number.isFinite(t) || parents.length === 0) return -1;
for (let i = parents.length - 1; i >= 0; i--) {
if (t >= parents[i].start) return i;
}
return -1;
}
function _scrollSectionPracticeChipIntoView(chip) {
if (!chip) return;
chip.scrollIntoView({ block: 'nearest', inline: 'nearest' });
}
export function _updateSectionPracticeHighlight(ct) {
const scroll = document.getElementById('section-practice-scroll');
if (!scroll) return;
const chips = scroll.querySelectorAll('.section-practice-chip[data-parent-idx]');
if (!chips.length) return;
const followEnabled = !_sectionPracticeMode && _sectionPracticeBarIsReady();
const followParent = followEnabled ? _sectionPracticeParentIndexAtTime(ct) : -1;
chips.forEach((chip) => {
const idx = Number(chip.dataset.parentIdx);
chip.classList.toggle('is-selected', idx === _sectionPracticeActiveParent);
chip.classList.toggle('is-playing', followEnabled && idx === followParent);
});
if (followEnabled && followParent >= 0 && followParent !== _sectionPracticeFollowParent) {
_sectionPracticeFollowParent = followParent;
const chip = scroll.querySelector(`.section-practice-chip[data-parent-idx="${followParent}"]`);
_scrollSectionPracticeChipIntoView(chip);
} else if (!followEnabled) {
_sectionPracticeFollowParent = -1;
}
_syncSectionPracticePieceUi();
}
export function _maybeRefreshSectionPracticeDuration(dur) {
if (_sectionPracticeDurSynced || !dur || _sectionPracticeRanges.length === 0) return;
const rebuilt = _buildSectionPracticeRanges();
if (!rebuilt.length) return;
const prevEnd = _sectionPracticeRanges[_sectionPracticeRanges.length - 1].end;
const nextEnd = rebuilt[rebuilt.length - 1].end;
if (Math.abs(prevEnd - nextEnd) > 0.05) {
_sectionPracticeDurSynced = true;
renderSectionPracticeBar();
} else {
_sectionPracticeDurSynced = true;
}
}
// Re-render when section metadata appears (before audio duration is known).
export function _ensureSectionPracticeBar() {
if (_sectionPracticeSourceSections().length === 0) return;
if (!_sectionPracticeBarIsReady()) {
renderSectionPracticeBar();
}
}
// ── Resets app.js used to perform by hand ───────────────────────────────────
// clearLoop() and changeArrangement() used to reach in and zero these scalars
// directly. They cannot now (an imported binding is read-only), and they should not
// have to: the module owns its own invariants.
/** Drop the current section selection. Called by app.js's clearLoop(). */
export function resetSelection() {
_sectionPracticeSelected = -1;
_sectionPracticeWholeSection = false;
_sectionPracticeSavedPartIndex = 0;
}
/**
* Force the next bar render to rebuild its parents, even when the new arrangement
* happens to have the same parent count. Called by app.js's changeArrangement().
*/
export function invalidateParentCount() {
_sectionPracticeLastParentCount = -1;
}