diff --git a/flow-state/package.json b/flow-state/package.json index 17b7959..fd109a1 100644 --- a/flow-state/package.json +++ b/flow-state/package.json @@ -8,7 +8,7 @@ "build": "vite build", "preview": "vite preview", "lint:scenes": "node tools/lint-scenes.js", - "test": "node --test test/" + "test": "node --test test/*.test.js" }, "license": "ISC", "devDependencies": { diff --git a/flow-state/src/main.js b/flow-state/src/main.js index 72c57d2..374df10 100644 --- a/flow-state/src/main.js +++ b/flow-state/src/main.js @@ -95,18 +95,26 @@ document.addEventListener('drop', (e) => { // ---------------------------------------------------------------- transport -function seekTo(frame) { +function seekTo(frame, options = {}) { if (!state.show.ready) return; const clamped = Math.max(0, Math.min(state.show.frameCount - 1, Math.round(frame))); dom.audio.currentTime = clamped / state.show.fps; - state.show.seek(clamped); + state.show.seek(clamped, options); strip.setFrame(clamped); } function togglePlay() { if (!state.show.ready) return; state.playing = !state.playing; - if (state.playing) dom.audio.play(); else dom.audio.pause(); + if (state.playing) { + dom.audio.play().catch((err) => { + console.warn('[flow-state] playback failed:', err); + state.playing = false; + dom.play.textContent = '▶'; + }); + } else { + dom.audio.pause(); + } dom.play.textContent = state.playing ? '❚❚' : '▶'; } @@ -376,6 +384,7 @@ function resize() { window.addEventListener('resize', resize); let lastPanelSection = -1; +let lastRenderedFrame = -1; function frame(now) { requestAnimationFrame(frame); @@ -393,7 +402,10 @@ function frame(now) { if (dom.audio.ended) { state.playing = false; dom.play.textContent = '▶'; } } - show.present(show.renderFrame(show.timeline.frame)); + if (state.playing || show.timeline.frame !== lastRenderedFrame) { + show.present(show.renderFrame(show.timeline.frame)); + lastRenderedFrame = show.timeline.frame; + } strip.setFrame(show.timeline.frame); strip.draw(); diff --git a/flow-state/src/ui/TimelineStrip.js b/flow-state/src/ui/TimelineStrip.js index 5d4be34..c01be23 100644 --- a/flow-state/src/ui/TimelineStrip.js +++ b/flow-state/src/ui/TimelineStrip.js @@ -23,14 +23,30 @@ export class TimelineStrip { this.frame = 0; this.hoverFrame = -1; this.loopSection = -1; + this.isDragging = false; - canvas.addEventListener('pointerdown', (e) => this._seekFromEvent(e)); + canvas.addEventListener('pointerdown', (e) => { + this.isDragging = true; + this._seekFromEvent(e, { warmup: false }); + }); canvas.addEventListener('pointermove', (e) => { const rect = canvas.getBoundingClientRect(); this.hoverFrame = this._frameAt(e.clientX - rect.left, rect.width); - if (e.buttons & 1) this._seekFromEvent(e); + if (this.isDragging || (e.buttons & 1)) { + this.isDragging = true; + this._seekFromEvent(e, { warmup: false }); + } }); canvas.addEventListener('pointerleave', () => { this.hoverFrame = -1; }); + + window.addEventListener('pointerup', () => { + if (this.isDragging) { + this.isDragging = false; + if (this.onSeek && this.show && this.show.ready) { + this.onSeek(this.frame, { warmup: true }); + } + } + }); } setShow(show) { this.show = show; } @@ -42,10 +58,10 @@ export class TimelineStrip { return Math.round((x / Math.max(1, width)) * (this.show.frameCount - 1)); } - _seekFromEvent(event) { + _seekFromEvent(event, options = {}) { if (!this.onSeek || !this.show || !this.show.ready) return; const rect = this.canvas.getBoundingClientRect(); - this.onSeek(this._frameAt(event.clientX - rect.left, rect.width)); + this.onSeek(this._frameAt(event.clientX - rect.left, rect.width), options); } resize() {