200 lines
7.4 KiB
JavaScript
200 lines
7.4 KiB
JavaScript
// scrubber.js
|
|
|
|
export class SubtickScrubber {
|
|
constructor(elementId, frame, diorama) {
|
|
this.frame = frame;
|
|
this.diorama = diorama;
|
|
this.container = diorama.element;
|
|
|
|
this.playMode = 'paused'; // 'paused' | 'realtime' | 'subtick'
|
|
this.speed = 1.0; // Ticks per second scaling
|
|
|
|
this.lastFrameTime = performance.now();
|
|
this.playbackAccumulator = 0;
|
|
|
|
// Hook into the Diorama tap event we just created
|
|
this.diorama.onTap = () => {
|
|
if (this.playMode !== 'paused') this.playMode = 'paused';
|
|
else this.playMode = 'realtime';
|
|
this.syncUI();
|
|
};
|
|
|
|
this.buildUI();
|
|
this.frame.subscribe(() => this.syncUI());
|
|
|
|
// Start playback loop
|
|
this._loop = this.loop.bind(this);
|
|
requestAnimationFrame(this._loop);
|
|
}
|
|
|
|
buildUI() {
|
|
this.container.style.cssText = `
|
|
font-family: monospace; background: #333; padding: 10px;
|
|
border-radius: 4px; display: flex; flex-direction: column; gap: 8px;
|
|
`;
|
|
|
|
// Secondary Row (Subticks)
|
|
this.subRow = document.createElement('div');
|
|
this.subRow.style.cssText = `display: flex; gap: 10px; align-items: center;`;
|
|
this.subRow.innerHTML = `
|
|
<button id="btn-play-sub" style="width: 40px;">S-Play</button>
|
|
<button id="btn-prev-sub"><</button>
|
|
<div style="flex-grow: 1; position: relative;">
|
|
<input type="range" id="slider-sub" min="0" max="1" step="1" style="width: 100%;">
|
|
</div>
|
|
<button id="btn-next-sub">></button>
|
|
`;
|
|
|
|
// Primary Row (Ticks)
|
|
this.tickRow = document.createElement('div');
|
|
this.tickRow.style.cssText = `display: flex; gap: 10px; align-items: center;`;
|
|
|
|
// Determine absolute timeline boundaries
|
|
const maxTime = this.frame.world.events.length > 0 ?
|
|
this.frame.world.events[this.frame.world.events.length - 1].time : 0;
|
|
|
|
this.tickRow.innerHTML = `
|
|
<button id="btn-play" style="width: 40px;">Play</button>
|
|
<button id="btn-prev"><</button>
|
|
<div style="flex-grow: 1; position: relative;">
|
|
<input type="range" id="slider-tick" min="0" max="${Math.max(1, maxTime)}" step="0.1" style="width: 100%;">
|
|
</div>
|
|
<button id="btn-next">></button>
|
|
`;
|
|
|
|
this.container.appendChild(this.subRow);
|
|
this.container.appendChild(this.tickRow);
|
|
|
|
this.bindEvents();
|
|
}
|
|
|
|
bindEvents() {
|
|
const getEl = id => this.container.querySelector('#' + id);
|
|
|
|
// --- Primary Actions ---
|
|
getEl('btn-play').onclick = () => {
|
|
this.playMode = this.playMode === 'realtime' ? 'paused' : 'realtime';
|
|
this.syncUI();
|
|
};
|
|
|
|
getEl('btn-prev').onclick = () => {
|
|
this.playMode = 'paused';
|
|
this.frame.seek(Math.floor(this.frame.currentTime - 1));
|
|
};
|
|
|
|
getEl('btn-next').onclick = () => {
|
|
this.playMode = 'paused';
|
|
this.frame.seek(Math.floor(this.frame.currentTime + 1));
|
|
};
|
|
|
|
const tickSlider = getEl('slider-tick');
|
|
tickSlider.oninput = () => {
|
|
this.playMode = 'paused';
|
|
this.frame.seek(parseFloat(tickSlider.value));
|
|
};
|
|
|
|
// --- Secondary Actions ---
|
|
getEl('btn-play-sub').onclick = () => {
|
|
this.playMode = this.playMode === 'subtick' ? 'paused' : 'subtick';
|
|
this.syncUI();
|
|
};
|
|
|
|
getEl('btn-prev-sub').onclick = () => {
|
|
this.playMode = 'paused';
|
|
this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex - 1);
|
|
};
|
|
|
|
getEl('btn-next-sub').onclick = () => {
|
|
this.playMode = 'paused';
|
|
this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1);
|
|
};
|
|
|
|
const subSlider = getEl('slider-sub');
|
|
subSlider.oninput = () => {
|
|
this.playMode = 'paused';
|
|
// Translate the localized sub-slider value back into an absolute global index
|
|
const localValue = parseInt(subSlider.value);
|
|
this.frame.seekIndex((this.currentTickStartIndex - this.frame.world.initialIndex) + localValue);
|
|
};
|
|
}
|
|
|
|
syncUI() {
|
|
const getEl = id => this.container.querySelector('#' + id);
|
|
|
|
getEl('btn-play').textContent = this.playMode === 'realtime' ? '||' : 'Play';
|
|
getEl('btn-play-sub').textContent = this.playMode === 'subtick' ? '||' : 'S-Play';
|
|
|
|
// Hide secondary scrubber in realtime mode
|
|
this.subRow.style.display = this.playMode === 'realtime' ? 'none' : 'flex';
|
|
|
|
// Sync Primary Slider
|
|
getEl('slider-tick').value = Math.max(0, this.frame.currentTime);
|
|
|
|
// --- Calculate Localized Subtick Slider Bounds ---
|
|
const events = this.frame.world.events;
|
|
let tickStartIdx = 0;
|
|
let tickEventCount = 0;
|
|
|
|
// Find the absolute bounds of the current tick in the event array
|
|
for (let i = 0; i < events.length; i++) {
|
|
if (events[i].time === this.frame.currentTime) {
|
|
if (tickEventCount === 0) tickStartIdx = i;
|
|
tickEventCount++;
|
|
}
|
|
}
|
|
|
|
this.currentTickStartIndex = tickStartIdx;
|
|
const subSlider = getEl('slider-sub');
|
|
subSlider.max = tickEventCount;
|
|
|
|
// Calculate where the cursor is relative to the start of this specific tick
|
|
let localCursor = this.frame.currentIndex - tickStartIdx;
|
|
if (localCursor < 0) localCursor = 0;
|
|
if (localCursor > tickEventCount) localCursor = tickEventCount;
|
|
|
|
subSlider.value = localCursor;
|
|
}
|
|
|
|
loop(time) {
|
|
const dt = (time - this.lastFrameTime) / 1000;
|
|
this.lastFrameTime = time;
|
|
|
|
const maxTime = this.frame.world.events.length > 0 ?
|
|
this.frame.world.events[this.frame.world.events.length - 1].time : 0;
|
|
|
|
if (this.playMode === 'realtime') {
|
|
// 20 ticks per second * speed multiplier
|
|
this.playbackAccumulator += dt * 20 * this.speed;
|
|
|
|
if (this.playbackAccumulator >= 1.0) {
|
|
const ticksToAdvance = Math.floor(this.playbackAccumulator);
|
|
this.playbackAccumulator -= ticksToAdvance;
|
|
|
|
const nextTime = this.frame.currentTime + ticksToAdvance;
|
|
if (nextTime > maxTime) {
|
|
this.frame.seek(maxTime);
|
|
this.playMode = 'paused'; // Auto-pause at end
|
|
} else {
|
|
this.frame.seek(nextTime);
|
|
}
|
|
}
|
|
} else if (this.playMode === 'subtick') {
|
|
// Slower playback for sequence animation (e.g., 4 events per second)
|
|
this.playbackAccumulator += dt * 4 * this.speed;
|
|
|
|
if (this.playbackAccumulator >= 1.0) {
|
|
this.playbackAccumulator = 0;
|
|
|
|
if (this.frame.currentIndex >= this.frame.world.events.length) {
|
|
this.playMode = 'paused'; // Auto-pause at end
|
|
} else {
|
|
this.frame.seekIndex(this.frame.currentIndex - this.frame.world.initialIndex + 1);
|
|
}
|
|
}
|
|
} else {
|
|
this.playbackAccumulator = 0; // Reset accumulator when paused
|
|
}
|
|
|
|
requestAnimationFrame(this._loop);
|
|
}
|
|
} |