Edit region: Loop-in-3D round-trip between player and Song Editor (#575)

* Add "Edit region" + Loop-in-3D handoff between player and Song Editor

Wires the player half of the Editor ⇄ 3D Highway region round-trip
(editor half is in feedback-plugin-editor).

Highway → Editor:
- New "✎ Edit region" button in the loop controls (v2 and v3) opens the
  Song Editor scrolled to the active A–B loop — or, when none is set, the
  section under the playhead (or a short window around it).
- A "↩ Editor" button appears after a Loop-in-3D handoff to return to the
  exact edit position you came from.
- Both are hidden unless the editor plugin is loaded (typeof
  window.editSong) and gated by _updateEditRegionBtn.

Editor → Highway:
- A one-shot song:ready listener consumes window._pendingHighwayLoop set
  by the editor's "Loop in 3D" button — after playSong()'s own clearLoop()
  has run — arming setLoop(a,b) over the region and auto-starting playback.
  Filename-guarded so a cancelled handoff can't arm a stale loop on an
  unrelated song.

Reuses the existing A/B loop API; no new looping engine. Buttons added to
both static/index.html (v2) and static/v3/index.html (separate file —
v2 markup doesn't carry over), using already-scanned Tailwind classes.

New globals editRegionInEditor / returnToEditorFromHighway; helpers
_resolveEditRegion / _updateEditRegionBtn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: topkoa <topkoa@gmail.com>

* fix(loop-in-3d): use canonical window.feedBack namespace (post-#537)

The new song:ready loop-applier landed on the legacy window.slopsmith
alias because the branch predated the slopsmith->feedBack rename (#537).
Normalize it to window.feedBack like the rest of core; the alias would
have worked but leaves the lone slopsmith reference in the file.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Signed-off-by: topkoa <topkoa@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
K. O. A.
2026-06-23 11:38:51 +02:00
committed by GitHub
co-authored by Claude Opus 4.8 byrongamatos
parent 7fb568c727
commit 7399a2ac63
3 changed files with 125 additions and 0 deletions
+110
View File
@@ -5759,6 +5759,27 @@ window.feedBack.on('song:ready', () => {
Promise.resolve(togglePlay()).catch((err) => console.warn('[app] autoplay failed:', err));
});
// Editor → Highway handoff (Editor ⇄ 3D Highway region round-trip). The
// editor's "Loop in 3D" button stashes a pending loop + return context, then
// calls playSong(). Once the chart is ready (playSong's own clearLoop() has
// already run, so the loop won't be wiped), arm the loop over the selected
// region and start playback so the user lands inside the loop directly.
window.feedBack.on('song:ready', () => {
_updateEditRegionBtn();
const pend = window._pendingHighwayLoop;
if (!pend) return;
// Only apply to the song it was set for — a cancelled/failed handoff
// must not arm a stale loop on an unrelated song loaded later.
const want = pend.returnCtx && pend.returnCtx.filename;
if (want && currentFilename && want !== currentFilename) return;
window._pendingHighwayLoop = null;
window._highwayReturnCtx = pend.returnCtx || null;
Promise.resolve(setLoop(pend.a, pend.b))
.then((ok) => { if (ok && !isPlaying) return togglePlay(); })
.catch((err) => console.warn('[app] loop-in-3d apply failed:', err));
_updateEditRegionBtn();
});
// Auto-exit: when the song ends, return to the launching menu. A scoring
// plugin that shows an end-of-song results screen calls holdAutoExit() to
// defer this; the user closing that screen (its Close button calls
@@ -7219,8 +7240,97 @@ function updateLoopUI() {
} else {
label.textContent = '';
}
_updateEditRegionBtn();
}
// ── Highway → Editor handoff ("Edit region") ────────────────────────────
// The flip side of the editor's "Loop in 3D" button: jump from the player
// to the Song Editor scrolled to the region you're looking at, edit, then
// (via the editor's Loop-in-3D) come straight back. Reuses the existing
// A/B loop as the region, falling back to the section under the playhead.
// Resolve the region to edit: the active A/B loop if set, else the section
// containing the playhead, else a short window around it. All in seconds.
function _resolveEditRegion() {
if (loopA !== null && loopB !== null) return { a: loopA, b: loopB };
const t = _audioTime();
try {
const secs = (highway && typeof highway.getSections === 'function')
? highway.getSections() : [];
if (Array.isArray(secs) && secs.length) {
let start = null, end = null;
for (let i = 0; i < secs.length; i++) {
const st = _sectionPracticeStartTime(secs[i]);
if (!Number.isFinite(st)) continue;
if (st <= t + 1e-6) {
start = st;
const nx = secs[i + 1] ? _sectionPracticeStartTime(secs[i + 1]) : NaN;
end = Number.isFinite(nx) ? nx : null;
} else if (start !== null) {
break;
}
}
if (start !== null) return { a: start, b: (end !== null && end > start) ? end : start + 8 };
}
} catch (_) { /* fall through to the window default */ }
return { a: Math.max(0, t - 4), b: t + 4 };
}
// Enable "Edit region" whenever the editor plugin is present and a song is
// loaded; show "↩ Editor" only while a return context is pending.
function _updateEditRegionBtn() {
const hasEditor = typeof window.editSong === 'function';
const editBtn = document.getElementById('btn-edit-region');
if (editBtn) {
editBtn.classList.toggle('hidden', !hasEditor);
editBtn.disabled = !currentFilename;
}
const retBtn = document.getElementById('btn-return-editor');
if (retBtn) {
retBtn.classList.toggle('hidden', !(hasEditor && window._highwayReturnCtx));
}
}
// Open the Song Editor at the current region.
function editRegionInEditor() {
if (typeof window.editSong !== 'function' || !currentFilename) return;
const region = _resolveEditRegion();
let arrangement = 0;
try {
const si = highway && typeof highway.getSongInfo === 'function' ? highway.getSongInfo() : null;
if (si && typeof si.arrangement_index === 'number' && si.arrangement_index >= 0) {
arrangement = si.arrangement_index;
}
} catch (_) { /* default to 0 */ }
window._editorPendingView = {
filename: currentFilename,
arrangement,
barSel: { startTime: region.a, endTime: region.b },
returnToHighway: true,
};
window.editSong(currentFilename);
}
window.editRegionInEditor = editRegionInEditor;
// Return from the editor to the highway loop we came from (set by the
// song:ready applier above). The editor consumes _editorPendingView to
// restore the exact edit position; here we just navigate back.
function returnToEditorFromHighway() {
const ctx = window._highwayReturnCtx;
if (!ctx || typeof window.editSong !== 'function') return;
window._highwayReturnCtx = null;
window._editorPendingView = {
filename: ctx.filename,
arrangement: ctx.arrangement,
scrollX: ctx.scrollX,
zoom: ctx.zoom,
cursorTime: ctx.cursorTime,
barSel: ctx.barSel,
};
window.editSong(ctx.filename);
}
window.returnToEditorFromHighway = returnToEditorFromHighway;
// ── Section Practice Bar ────────────────────────────────────────────────
// One-click looping over song section markers (highway.getSections —
// same array as 3D highway bundle.sections / "Now / Up Next").
+7
View File
@@ -578,6 +578,13 @@
<option value="">Saved Loops</option>
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="px-2 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-500 hover:text-red-400 transition hidden" title="Delete selected loop"></button>
<!-- Editor ⇄ 3D Highway round-trip. "Edit region" opens the Song Editor
scrolled to the active loop (or the section at the playhead).
"↩ Editor" returns to the editing position you came from; it only
appears after a Loop-in-3D handoff. Both are hidden when the editor
plugin isn't loaded (state managed by _updateEditRegionBtn). -->
<button onclick="editRegionInEditor()" id="btn-edit-region" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="px-3 py-1.5 bg-dark-600 hover:bg-dark-500 rounded-lg text-xs text-gray-300 transition hidden" title="Return to the editor where you left off">↩ Editor</button>
<button onclick="showScreen('home')" class="ml-auto px-3 py-1.5 bg-dark-600 hover:bg-red-900/50 rounded-lg text-xs text-gray-400 hover:text-red-400 transition">✕ Close</button>
</div>
</div>
+8
View File
@@ -806,6 +806,14 @@
</select>
<button onclick="deleteSelectedLoop()" id="btn-loop-delete" class="v3-pop-btn hidden" title="Delete selected loop"></button>
</div>
<!-- Editor ⇄ 3D Highway round-trip (mirrors the v2 loop cluster). -->
<div class="v3-pop-row">
<span class="v3-pop-label">Editor</span>
<span class="flex items-center gap-1 flex-wrap">
<button onclick="editRegionInEditor()" id="btn-edit-region" class="v3-pop-btn hidden" title="Edit this region in the Song Editor">✎ Edit region</button>
<button onclick="returnToEditorFromHighway()" id="btn-return-editor" class="v3-pop-btn hidden" title="Return to the editor where you left off">↩ Editor</button>
</span>
</div>
<button onclick="showScreen('home')" class="v3-pop-close" title="Close player">✕ Close player</button>
</div>
</div>