diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd5d1a..7e28c9b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added +### Added + +- **Practice loops are easier to control and understand** — A/B and section + loops now share clear start/count-in/repeat choices, can be saved per song, + show their bounds on the timeline, and remain visible in the game HUD. + Seeking stays free inside the loop while outside seeks restart from A using + the selected first-pass behavior. - **`chart-transform` capability domain (#952)** — plugins can now remap the chart before rendering and scoring through a core-owned provider coordinator. Synchronous transforms run after difficulty filtering; host diff --git a/static/capabilities/playback.js b/static/capabilities/playback.js index 2593f82..9e619a9 100644 --- a/static/capabilities/playback.js +++ b/static/capabilities/playback.js @@ -1034,6 +1034,7 @@ else if (name === 'loop-restarted') { const startTime = _number(source.loopA, null); const endTime = _number(source.loopB, null); + const lastRestartAt = _now(); if (startTime != null && endTime != null) { // The core loop bridge emits one legacy loop:restart event for // both the initial pass and later wraps. Promote a previously @@ -1046,9 +1047,12 @@ enabled: true, state: 'active', requesterId: source.requesterId, + lastRestartAt, }); + } else if (currentSession.loop) { + currentSession.loop.lastRestartAt = lastRestartAt; + currentSession.media.loop = _clone(currentSession.loop); } - if (currentSession.loop) currentSession.loop.lastRestartAt = _now(); } else if (name === 'loop-stale') { if (currentSession.loop) currentSession.loop.state = 'stale'; } diff --git a/static/js/juce-audio.js b/static/js/juce-audio.js index c916195..577ebf3 100644 --- a/static/js/juce-audio.js +++ b/static/js/juce-audio.js @@ -900,7 +900,7 @@ export let _resetJuceAudioShimChain = function () {}; if (wantsPause && seekTime !== undefined) { enqueue(async (gen) => { const r = await _audioSeek(seekTime, 'audio-element-shim', { - restartActiveLoopWhilePlaying: true, + restartActiveLoopWhilePlaying: forUpcomingPlay, }); if (!r.completed) return; // seek cancelled by teardown if (gen !== _juceShimGen) return; diff --git a/static/style.css b/static/style.css index 5af9476..3225d38 100644 --- a/static/style.css +++ b/static/style.css @@ -77,6 +77,10 @@ html { scroll-behavior: smooth; } border-color: rgba(64, 128, 224, 0.5); color: #bfdbfe; } +.section-practice-pill.section-practice-pill--section-selected { + border-color: #4080e0; + color: #93c5fd; +} .section-practice-pill.section-practice-pill--active { border-color: #4080e0; color: #93c5fd; diff --git a/static/v3/v3.css b/static/v3/v3.css index 2053e2e..6ad3e6a 100644 --- a/static/v3/v3.css +++ b/static/v3/v3.css @@ -630,6 +630,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important; border-color: rgba(203, 213, 225, .85); } #v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"], +#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--section-selected, #v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active, #v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--armed { color: #e2e8f0; @@ -637,6 +638,7 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important; box-shadow: none; } #v3-player-rail .section-practice-control--v3 .section-practice-pill[aria-expanded="true"] .v3-rail-border, +#v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--section-selected .v3-rail-border, #v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--active .v3-rail-border, #v3-player-rail .section-practice-control--v3 .section-practice-pill.section-practice-pill--armed .v3-rail-border { border-color: #22d3ee; @@ -824,14 +826,14 @@ html[data-scoreboard="off"] #v3-live-performance-hud { display: none !important; width: .85rem; height: .85rem; fill: none; - stroke: currentColor; + stroke: currentcolor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; opacity: .9; } .v3-loop-indicator-label { - color: currentColor; + color: currentcolor; font-weight: 750; letter-spacing: .08em; text-transform: uppercase; diff --git a/tests/js/loop_ui.test.js b/tests/js/loop_ui.test.js index c6f5cd0..933b2b6 100644 --- a/tests/js/loop_ui.test.js +++ b/tests/js/loop_ui.test.js @@ -10,7 +10,9 @@ const ROOT = path.join(__dirname, '..', '..'); const HTML = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'index.html'), 'utf8'); const SECTION = fs.readFileSync(path.join(ROOT, 'static', 'js', 'section-practice.js'), 'utf8'); const LOOPS = fs.readFileSync(path.join(ROOT, 'static', 'js', 'loops.js'), 'utf8'); +const JUCE_AUDIO = fs.readFileSync(path.join(ROOT, 'static', 'js', 'juce-audio.js'), 'utf8'); const APP = fs.readFileSync(path.join(ROOT, 'static', 'app.js'), 'utf8'); +const STYLE_CSS = fs.readFileSync(path.join(ROOT, 'static', 'style.css'), 'utf8'); const V3_CSS = fs.readFileSync(path.join(ROOT, 'static', 'v3', 'v3.css'), 'utf8'); function practiceMarkup() { @@ -123,6 +125,29 @@ test('returning to A gives the loop indicator one restrained pulse', () => { assert.match(V3_CSS, /@media\s*\(prefers-reduced-motion:\s*reduce\)/); }); +test('selected Section Practice remains visible without replacing loop lifecycle styling', () => { + assert.match(SECTION, /classList\.toggle\('section-practice-pill--section-selected',\s*_sectionPracticeMode\)/); + assert.match(STYLE_CSS, /\.section-practice-pill\.section-practice-pill--section-selected\s*\{/); + assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--section-selected/); + assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--active/); + assert.match(V3_CSS, /\.section-practice-pill\.section-practice-pill--armed/); +}); + +test('JUCE pause-and-seek restarts an outside loop only when playback follows', () => { + const start = JUCE_AUDIO.indexOf('function flushJuceShimBatchNow('); + const end = JUCE_AUDIO.indexOf('function scheduleJuceShimBatchFlush(', start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const flush = JUCE_AUDIO.slice(start, end); + const pauseAndSeekStart = flush.indexOf('if (wantsPause && seekTime !== undefined)'); + const pauseAndSeekEnd = flush.indexOf('if (wantsPause) {', pauseAndSeekStart); + assert.notEqual(pauseAndSeekStart, -1); + assert.notEqual(pauseAndSeekEnd, -1); + const pauseAndSeek = flush.slice(pauseAndSeekStart, pauseAndSeekEnd); + assert.match(pauseAndSeek, /restartActiveLoopWhilePlaying:\s*forUpcomingPlay/); + assert.doesNotMatch(pauseAndSeek, /restartActiveLoopWhilePlaying:\s*true/); +}); + test('timeline seeks stay free while paused and restart active loops while playing', () => { assert.match(APP, /setLoopPlayStartTargetResolver\(\(requestedTime\)/); assert.match(APP, /setLoopRestartHandler\(async\s*\(\{\s*trigger\s*\}\)\s*=>\s*\{[\s\S]*startLoop\(\{/); diff --git a/tests/js/playback_domain.test.js b/tests/js/playback_domain.test.js index d75519f..18c0101 100644 --- a/tests/js/playback_domain.test.js +++ b/tests/js/playback_domain.test.js @@ -177,10 +177,11 @@ test('a loop restart promotes an armed observer snapshot without a second loop-s loopB: 7, currentTime: 3, }); - const loop = diagnosticsSnapshot(window).state.loop; - assert.equal(loop.state, 'active'); - assert.equal(loop.enabled, true); - assert.ok(loop.lastRestartAt); + const state = diagnosticsSnapshot(window).state; + assert.equal(state.loop.state, 'active'); + assert.equal(state.loop.enabled, true); + assert.ok(state.loop.lastRestartAt); + assert.equal(state.media.loop.lastRestartAt, state.loop.lastRestartAt); assert.equal(loopSetEvents.length, 1); });