From 50ff9bf927895e22837a7f6250da49cfa580e373 Mon Sep 17 00:00:00 2001 From: Jage9 Date: Sat, 28 Feb 2026 03:04:32 -0500 Subject: [PATCH] Preserve emit playback phase across range re-entry --- client/public/version.js | 2 +- client/src/audio/itemEmitRuntime.ts | 59 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/client/public/version.js b/client/public/version.js index 0902ea8..4c109bc 100644 --- a/client/public/version.js +++ b/client/public/version.js @@ -1,5 +1,5 @@ // Maintainer-controlled web client version. // Format: YYYY.MM.DD Rn (example: 2026.02.20 R2) -window.CHGRID_WEB_VERSION = "2026.02.28 R307"; +window.CHGRID_WEB_VERSION = "2026.02.28 R308"; // Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid. window.CHGRID_TIME_ZONE = "America/Detroit"; diff --git a/client/src/audio/itemEmitRuntime.ts b/client/src/audio/itemEmitRuntime.ts index 6660e92..67b3327 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -20,6 +20,16 @@ type EmitOutput = { panner: StereoPannerNode | null; }; +type EmitResumeState = { + soundUrl: string; + savedAtMs: number; + currentTimeSeconds: number; + playbackRate: number; + loopDelaySeconds: number; + durationSeconds: number | null; + wasPlaying: boolean; +}; + type EmitSpatialConfig = { range: number; directional: boolean; @@ -75,6 +85,7 @@ function resolveEmitLoopDelaySeconds(item: WorldItem): number { export class ItemEmitRuntime { private readonly outputs = new Map(); + private readonly resumeStateByItemId = new Map(); private readonly pendingEmitStarts = new Set(); private readonly nextEmitStartAtMs = new Map(); private readonly emitStartFailureCount = new Map(); @@ -91,6 +102,20 @@ export class ItemEmitRuntime { const preserveSchedule = options?.preserveSchedule === true; const output = this.outputs.get(itemId); if (output) { + if (preserveSchedule) { + const duration = Number(output.element.duration); + this.resumeStateByItemId.set(itemId, { + soundUrl: output.soundUrl, + savedAtMs: Date.now(), + currentTimeSeconds: Number.isFinite(output.element.currentTime) ? Math.max(0, output.element.currentTime) : 0, + playbackRate: Number.isFinite(output.element.playbackRate) && output.element.playbackRate > 0 ? output.element.playbackRate : 1, + loopDelaySeconds: output.loopDelaySeconds, + durationSeconds: Number.isFinite(duration) && duration > 0 ? duration : null, + wasPlaying: !output.element.paused, + }); + } else { + this.resumeStateByItemId.delete(itemId); + } output.element.pause(); output.element.removeEventListener('ended', output.onEnded); output.element.src = ''; @@ -162,6 +187,7 @@ export class ItemEmitRuntime { validIds.add(item.id); const existing = this.outputs.get(item.id); if (existing && existing.soundUrl === soundUrl) { + this.resumeStateByItemId.delete(item.id); continue; } if (existing) { @@ -196,6 +222,33 @@ export class ItemEmitRuntime { this.nextEmitStartAtMs.set(item.id, Date.now() + delaySeconds * 1000); }; element.addEventListener('ended', onEnded); + const resumeState = this.resumeStateByItemId.get(item.id); + if (resumeState && resumeState.soundUrl === soundUrl) { + const nowMs = Date.now(); + const elapsedSeconds = Math.max(0, (nowMs - resumeState.savedAtMs) / 1000); + const effectiveRate = resumeState.playbackRate > 0 ? resumeState.playbackRate : 1; + const durationSeconds = resumeState.durationSeconds; + if (durationSeconds && durationSeconds > 0) { + const cycleSeconds = durationSeconds + Math.max(0, resumeState.loopDelaySeconds); + const progressed = (resumeState.currentTimeSeconds + elapsedSeconds * effectiveRate) % cycleSeconds; + if (progressed < durationSeconds) { + const targetTime = Math.min(Math.max(0, progressed), Math.max(0, durationSeconds - 0.01)); + const applySeek = () => { + try { + element.currentTime = targetTime; + } catch { + // Ignore seek failures before metadata is fully available. + } + }; + applySeek(); + element.addEventListener('loadedmetadata', applySeek, { once: true }); + this.nextEmitStartAtMs.delete(item.id); + } else { + const delayRemainingSeconds = cycleSeconds - progressed; + this.nextEmitStartAtMs.set(item.id, nowMs + delayRemainingSeconds * 1000); + } + } + } const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination; if (this.audio.supportsStereoPanner()) { panner = audioCtx.createStereoPanner(); @@ -216,6 +269,7 @@ export class ItemEmitRuntime { gain, panner, }); + this.resumeStateByItemId.delete(item.id); this.tryStartEmitPlayback(item.id, element); } @@ -230,6 +284,11 @@ export class ItemEmitRuntime { this.nextEmitStartAtMs.delete(itemId); } } + for (const itemId of Array.from(this.resumeStateByItemId.keys())) { + if (!seenItemIds.has(itemId)) { + this.resumeStateByItemId.delete(itemId); + } + } } updateSpatialAudio(items: Map, playerPosition: { x: number; y: number }): void {