From e4aa6dbe3e08896101684a9a209a3f3b8c7cf964 Mon Sep 17 00:00:00 2001 From: David Allemang Date: Tue, 7 Jul 2026 14:22:18 -0400 Subject: [PATCH] player improvements --- src/components.css | 13 +--- src/controller.js | 146 ++++++++++++++++++++++++++++++++------------- 2 files changed, 108 insertions(+), 51 deletions(-) diff --git a/src/components.css b/src/components.css index c6cbe13f..53dba934 100644 --- a/src/components.css +++ b/src/components.css @@ -173,7 +173,7 @@ mc-diorama { padding: 5px; display: flex; flex-direction: column; - gap: 12px; + gap: 4px; color: white; user-select: none; border: 1px solid transparent; @@ -181,12 +181,8 @@ mc-diorama { & .scrubber-row { display: flex; - gap: 12px; + gap: 4px; align-items: center; - - & .btn { - min-width: 4ch; - } } & .track-container { @@ -260,12 +256,7 @@ mc-diorama { opacity: 1; pointer-events: auto; } - & .visualizer-ui { - background: rgba(20, 20, 20, 0.85); - backdrop-filter: blur(4px); - border: 1px solid rgba(255, 255, 255, 0.1); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); pointer-events: auto; } } \ No newline at end of file diff --git a/src/controller.js b/src/controller.js index 773a755b..b3446e49 100644 --- a/src/controller.js +++ b/src/controller.js @@ -31,6 +31,7 @@ export class SpacetimeController { this.isLooping = this.opts.loop; this.speed = this.opts.speed; this.lastFrameTime = performance.now(); + this.uiTickTime = 0; this.playbackAccumulator = 0; this.container.classList.add('spacetime-container'); @@ -232,10 +233,10 @@ export class SpacetimeController { } // --- TEMPORAL CONTROL --- - getMaxTime() { const evs = this.frame.world.events; - return evs.length > 0 ? evs[evs.length - 1].time : 0; + // Add +1 so the final tick plays out its full duration! + return evs.length > 0 ? evs[evs.length - 1].time + 1 : 0; } generateTickMarkers() { @@ -296,8 +297,15 @@ export class SpacetimeController { }; getEl('btn-play').onclick = () => { - if (this.playMode === 'realtime') this.playMode = 'paused'; else { - if (this.frame.currentTime >= this.getMaxTime() || this.frame.currentTime < 0) this.frame.seek(0); + if (this.playMode === 'realtime') this.playMode = 'paused'; + else { + // Check if time is exhausted OR if all events have been processed + const isAtEnd = this.frame.currentTime >= this.getMaxTime() || this.frame.currentIndex >= this.frame.world.events.length; + + if (isAtEnd || this.frame.currentTime < 0) { + this.frame.seek(0); + this.uiTickTime = 0; // Explicitly reset UI sync + } this.playMode = 'realtime'; } this.syncUI(); @@ -305,65 +313,85 @@ export class SpacetimeController { getEl('btn-prev').onclick = () => { this.playMode = 'paused'; - let activeTime = -Infinity; - for (const ev of this.frame.world.events) if (ev.time <= this.frame.currentTime) activeTime = ev.time; + let prevTime = Math.floor(this.uiTickTime - 1); - let prevTime = -1; - for (let i = this.frame.world.events.length - 1; i >= 0; i--) { - if (this.frame.world.events[i].time < activeTime) { - prevTime = this.frame.world.events[i].time; - break; - } - } if (prevTime < 0) prevTime = this.isLooping ? this.getMaxTime() : 0; + this.frame.seek(prevTime); + this.uiTickTime = prevTime; // Explicit jump this.syncUI(); }; getEl('btn-next').onclick = () => { this.playMode = 'paused'; - let nextTime = this.getMaxTime() + 1; - for (const ev of this.frame.world.events) { - if (ev.time > this.frame.currentTime) { - nextTime = ev.time; - break; - } - } + let nextTime = Math.floor(this.uiTickTime + 1); + if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime(); + this.frame.seek(nextTime); + this.uiTickTime = nextTime; // Explicit jump this.syncUI(); }; this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { - this.frame.seek(Math.round(pct * this.getMaxTime())); + const targetTime = Math.round(pct * this.getMaxTime()); + this.frame.seek(targetTime); + this.uiTickTime = targetTime; // Explicit jump. Do not snap to event index! this.syncUI(); }); if (this.opts.subticks) { getEl('btn-play-sub').onclick = () => { - this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick'; + if (this.playMode === 'subtick') this.playMode = 'paused'; + else { + // Check if all events are processed + if (this.frame.currentIndex >= this.frame.world.events.length) { + this.frame.seekIndex(this.frame.world.initialIndex || 0); + const events = this.frame.world.events; + this.uiTickTime = events.length > 0 ? events[0].time : 0; + } + this.playMode = 'subtick'; + } this.syncUI(); }; getEl('btn-prev-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex - 1; - if (idx < this.frame.world.initialIndex) idx = this.isLooping ? this.frame.world.events.length : this.frame.world.initialIndex; + const events = this.frame.world.events; + const initialIndex = this.frame.world.initialIndex || 0; + + if (idx < initialIndex) idx = this.isLooping ? events.length : initialIndex; this.frame.seekIndex(idx); + + // Allow the UI to fall backwards naturally + if (idx < events.length && idx >= 0) this.uiTickTime = events[idx].time; this.syncUI(); }; getEl('btn-next-sub').onclick = () => { this.playMode = 'paused'; let idx = this.frame.currentIndex + 1; - if (idx > this.frame.world.events.length) idx = this.isLooping ? this.frame.world.initialIndex : this.frame.world.events.length; + const events = this.frame.world.events; + const initialIndex = this.frame.world.initialIndex || 0; + + if (idx > events.length) idx = this.isLooping ? initialIndex : events.length; this.frame.seekIndex(idx); + + // Force UI to advance if the button naturally crossed the threshold + if (idx < events.length && idx >= 0) { + this.uiTickTime = events[idx].time; + } else if (events.length > 0) { + this.uiTickTime = events[events.length - 1].time; + } this.syncUI(); }; this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => { if (this.currentTickEventCount === 0) return; - this.frame.seekIndex(this.currentTickStartIndex + Math.round(pct * this.currentTickEventCount)); + let targetIdx = this.currentTickStartIndex + Math.round(pct * this.currentTickEventCount); + this.frame.seekIndex(targetIdx); + // Intentionally omit updating uiTickTime to let scrubbing "stick" to the current tick. this.syncUI(); }); } @@ -399,8 +427,19 @@ export class SpacetimeController { this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; } + // Force uiTickTime to perfectly match realtime flow + if (this.playMode === 'realtime') { + this.uiTickTime = Math.floor(this.frame.currentTime); + } + const maxTime = this.getMaxTime(); - const displayTime = Math.max(0, this.frame.currentTime); + + // Fix: Decouple the UI tick track from realtime when scrubbed/paused + let displayTime = Math.max(0, this.frame.currentTime); + if (this.playMode !== 'realtime') { + displayTime = Math.max(0, this.uiTickTime); + } + const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0; getEl('fill-tick').style.width = `${tickPct}%`; @@ -409,16 +448,38 @@ export class SpacetimeController { if (!this.opts.subticks) return; const events = this.frame.world.events; - let activeTickTime = -Infinity; - for (const ev of events) { - if (ev.time <= this.frame.currentTime) activeTickTime = ev.time; else break; + if (events.length === 0) return; + + // --- Verify uiTickTime is valid for the current index --- + let startIdx = -1, count = 0; + for (let i = 0; i < events.length; i++) { + if (events[i].time === this.uiTickTime) { + if (startIdx === -1) startIdx = i; + count++; + } } - let startIdx = 0, count = 0; - for (let i = 0; i < events.length; i++) { - if (events[i].time === activeTickTime) { - if (count === 0) startIdx = i; - count++; + // Fix: Only auto-correct if we are NOT in realtime AND the tick isn't empty. + // This stops empty ticks from auto-snapping to filled ticks. + if (this.playMode !== 'realtime' && startIdx !== -1) { + // Because scrubbing to 100% means currentIndex == startIdx + count, + // this > condition strictly permits the inclusive UI overlap! + if (this.frame.currentIndex < startIdx || this.frame.currentIndex > startIdx + count) { + if (this.frame.currentIndex < events.length) { + this.uiTickTime = events[this.frame.currentIndex].time; + } else { + this.uiTickTime = events[events.length - 1].time; + } + + // Recalculate bounds for the newly corrected tick + startIdx = -1; + count = 0; + for (let i = 0; i < events.length; i++) { + if (events[i].time === this.uiTickTime) { + if (startIdx === -1) startIdx = i; + count++; + } + } } } @@ -455,35 +516,40 @@ export class SpacetimeController { const dt = (time - this.lastFrameTime) / 1000; this.lastFrameTime = time; const maxTime = this.getMaxTime(); - if (this.playMode === 'realtime') { let nextTime = this.frame.currentTime + dt * 20 * this.speed; if (nextTime >= maxTime) { - if (this.isLooping) { + if (this.isLooping && maxTime > 0) { this.frame.seek(nextTime % maxTime); } else { this.frame.seek(maxTime); + this.uiTickTime = maxTime; // Snap UI to the very end this.playMode = 'paused'; } } else { this.frame.seek(nextTime); } if (this.opts.timeline) this.syncUI(); - } else if (this.playMode === 'subtick') { this.playbackAccumulator += dt * 4 * this.speed; if (this.playbackAccumulator >= 1.0) { const steps = Math.floor(this.playbackAccumulator); this.playbackAccumulator -= steps; const nextIdx = this.frame.currentIndex + steps; + const events = this.frame.world.events; + const initialIndex = this.frame.world.initialIndex || 0; - if (nextIdx > this.frame.world.events.length) { - if (this.isLooping) this.frame.seekIndex(this.frame.world.initialIndex); else { - this.frame.seekIndex(this.frame.world.events.length); + if (nextIdx > events.length) { + if (this.isLooping) { + this.frame.seekIndex(initialIndex); + if (events.length > 0) this.uiTickTime = events[0].time; + } else { + this.frame.seekIndex(events.length); this.playMode = 'paused'; } } else { this.frame.seekIndex(nextIdx); + if (nextIdx < events.length) this.uiTickTime = events[nextIdx].time; } } if (this.opts.timeline) this.syncUI();