player improvements

This commit is contained in:
David Allemang
2026-07-07 14:22:18 -04:00
parent 73bec77fcc
commit e4aa6dbe3e
2 changed files with 108 additions and 51 deletions

View File

@@ -173,7 +173,7 @@ mc-diorama {
padding: 5px; padding: 5px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 4px;
color: white; color: white;
user-select: none; user-select: none;
border: 1px solid transparent; border: 1px solid transparent;
@@ -181,12 +181,8 @@ mc-diorama {
& .scrubber-row { & .scrubber-row {
display: flex; display: flex;
gap: 12px; gap: 4px;
align-items: center; align-items: center;
& .btn {
min-width: 4ch;
}
} }
& .track-container { & .track-container {
@@ -260,12 +256,7 @@ mc-diorama {
opacity: 1; opacity: 1;
pointer-events: auto; pointer-events: auto;
} }
& .visualizer-ui { & .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; pointer-events: auto;
} }
} }

View File

@@ -31,6 +31,7 @@ export class SpacetimeController {
this.isLooping = this.opts.loop; this.isLooping = this.opts.loop;
this.speed = this.opts.speed; this.speed = this.opts.speed;
this.lastFrameTime = performance.now(); this.lastFrameTime = performance.now();
this.uiTickTime = 0;
this.playbackAccumulator = 0; this.playbackAccumulator = 0;
this.container.classList.add('spacetime-container'); this.container.classList.add('spacetime-container');
@@ -232,10 +233,10 @@ export class SpacetimeController {
} }
// --- TEMPORAL CONTROL --- // --- TEMPORAL CONTROL ---
getMaxTime() { getMaxTime() {
const evs = this.frame.world.events; 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() { generateTickMarkers() {
@@ -296,8 +297,15 @@ export class SpacetimeController {
}; };
getEl('btn-play').onclick = () => { getEl('btn-play').onclick = () => {
if (this.playMode === 'realtime') this.playMode = 'paused'; else { if (this.playMode === 'realtime') this.playMode = 'paused';
if (this.frame.currentTime >= this.getMaxTime() || this.frame.currentTime < 0) this.frame.seek(0); 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.playMode = 'realtime';
} }
this.syncUI(); this.syncUI();
@@ -305,65 +313,85 @@ export class SpacetimeController {
getEl('btn-prev').onclick = () => { getEl('btn-prev').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let activeTime = -Infinity; let prevTime = Math.floor(this.uiTickTime - 1);
for (const ev of this.frame.world.events) if (ev.time <= this.frame.currentTime) activeTime = ev.time;
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; if (prevTime < 0) prevTime = this.isLooping ? this.getMaxTime() : 0;
this.frame.seek(prevTime); this.frame.seek(prevTime);
this.uiTickTime = prevTime; // Explicit jump
this.syncUI(); this.syncUI();
}; };
getEl('btn-next').onclick = () => { getEl('btn-next').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let nextTime = this.getMaxTime() + 1; let nextTime = Math.floor(this.uiTickTime + 1);
for (const ev of this.frame.world.events) {
if (ev.time > this.frame.currentTime) {
nextTime = ev.time;
break;
}
}
if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime(); if (nextTime > this.getMaxTime()) nextTime = this.isLooping ? 0 : this.getMaxTime();
this.frame.seek(nextTime); this.frame.seek(nextTime);
this.uiTickTime = nextTime; // Explicit jump
this.syncUI(); this.syncUI();
}; };
this.setupDraggableTrack('track-tick-container', 'track-tick-inner', (pct) => { 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(); this.syncUI();
}); });
if (this.opts.subticks) { if (this.opts.subticks) {
getEl('btn-play-sub').onclick = () => { 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(); this.syncUI();
}; };
getEl('btn-prev-sub').onclick = () => { getEl('btn-prev-sub').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let idx = this.frame.currentIndex - 1; 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); this.frame.seekIndex(idx);
// Allow the UI to fall backwards naturally
if (idx < events.length && idx >= 0) this.uiTickTime = events[idx].time;
this.syncUI(); this.syncUI();
}; };
getEl('btn-next-sub').onclick = () => { getEl('btn-next-sub').onclick = () => {
this.playMode = 'paused'; this.playMode = 'paused';
let idx = this.frame.currentIndex + 1; 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); 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.syncUI();
}; };
this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => { this.setupDraggableTrack('track-sub-container', 'track-sub-inner', (pct) => {
if (this.currentTickEventCount === 0) return; 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(); this.syncUI();
}); });
} }
@@ -399,8 +427,19 @@ export class SpacetimeController {
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex'; 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 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; const tickPct = maxTime > 0 ? (displayTime / maxTime) * 100 : 0;
getEl('fill-tick').style.width = `${tickPct}%`; getEl('fill-tick').style.width = `${tickPct}%`;
@@ -409,16 +448,38 @@ export class SpacetimeController {
if (!this.opts.subticks) return; if (!this.opts.subticks) return;
const events = this.frame.world.events; const events = this.frame.world.events;
let activeTickTime = -Infinity; if (events.length === 0) return;
for (const ev of events) {
if (ev.time <= this.frame.currentTime) activeTickTime = ev.time; else break; // --- 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; // Fix: Only auto-correct if we are NOT in realtime AND the tick isn't empty.
for (let i = 0; i < events.length; i++) { // This stops empty ticks from auto-snapping to filled ticks.
if (events[i].time === activeTickTime) { if (this.playMode !== 'realtime' && startIdx !== -1) {
if (count === 0) startIdx = i; // Because scrubbing to 100% means currentIndex == startIdx + count,
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; const dt = (time - this.lastFrameTime) / 1000;
this.lastFrameTime = time; this.lastFrameTime = time;
const maxTime = this.getMaxTime(); const maxTime = this.getMaxTime();
if (this.playMode === 'realtime') { if (this.playMode === 'realtime') {
let nextTime = this.frame.currentTime + dt * 20 * this.speed; let nextTime = this.frame.currentTime + dt * 20 * this.speed;
if (nextTime >= maxTime) { if (nextTime >= maxTime) {
if (this.isLooping) { if (this.isLooping && maxTime > 0) {
this.frame.seek(nextTime % maxTime); this.frame.seek(nextTime % maxTime);
} else { } else {
this.frame.seek(maxTime); this.frame.seek(maxTime);
this.uiTickTime = maxTime; // Snap UI to the very end
this.playMode = 'paused'; this.playMode = 'paused';
} }
} else { } else {
this.frame.seek(nextTime); this.frame.seek(nextTime);
} }
if (this.opts.timeline) this.syncUI(); if (this.opts.timeline) this.syncUI();
} else if (this.playMode === 'subtick') { } else if (this.playMode === 'subtick') {
this.playbackAccumulator += dt * 4 * this.speed; this.playbackAccumulator += dt * 4 * this.speed;
if (this.playbackAccumulator >= 1.0) { if (this.playbackAccumulator >= 1.0) {
const steps = Math.floor(this.playbackAccumulator); const steps = Math.floor(this.playbackAccumulator);
this.playbackAccumulator -= steps; this.playbackAccumulator -= steps;
const nextIdx = this.frame.currentIndex + 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 (nextIdx > events.length) {
if (this.isLooping) this.frame.seekIndex(this.frame.world.initialIndex); else { if (this.isLooping) {
this.frame.seekIndex(this.frame.world.events.length); this.frame.seekIndex(initialIndex);
if (events.length > 0) this.uiTickTime = events[0].time;
} else {
this.frame.seekIndex(events.length);
this.playMode = 'paused'; this.playMode = 'paused';
} }
} else { } else {
this.frame.seekIndex(nextIdx); this.frame.seekIndex(nextIdx);
if (nextIdx < events.length) this.uiTickTime = events[nextIdx].time;
} }
} }
if (this.opts.timeline) this.syncUI(); if (this.opts.timeline) this.syncUI();