Preserve emit playback phase across range re-entry
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
// Maintainer-controlled web client version.
|
// Maintainer-controlled web client version.
|
||||||
// Format: YYYY.MM.DD Rn (example: 2026.02.20 R2)
|
// 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.
|
// Optional display timezone for timestamps. Falls back to America/Detroit if unset/invalid.
|
||||||
window.CHGRID_TIME_ZONE = "America/Detroit";
|
window.CHGRID_TIME_ZONE = "America/Detroit";
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ type EmitOutput = {
|
|||||||
panner: StereoPannerNode | null;
|
panner: StereoPannerNode | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type EmitResumeState = {
|
||||||
|
soundUrl: string;
|
||||||
|
savedAtMs: number;
|
||||||
|
currentTimeSeconds: number;
|
||||||
|
playbackRate: number;
|
||||||
|
loopDelaySeconds: number;
|
||||||
|
durationSeconds: number | null;
|
||||||
|
wasPlaying: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
type EmitSpatialConfig = {
|
type EmitSpatialConfig = {
|
||||||
range: number;
|
range: number;
|
||||||
directional: boolean;
|
directional: boolean;
|
||||||
@@ -75,6 +85,7 @@ function resolveEmitLoopDelaySeconds(item: WorldItem): number {
|
|||||||
|
|
||||||
export class ItemEmitRuntime {
|
export class ItemEmitRuntime {
|
||||||
private readonly outputs = new Map<string, EmitOutput>();
|
private readonly outputs = new Map<string, EmitOutput>();
|
||||||
|
private readonly resumeStateByItemId = new Map<string, EmitResumeState>();
|
||||||
private readonly pendingEmitStarts = new Set<string>();
|
private readonly pendingEmitStarts = new Set<string>();
|
||||||
private readonly nextEmitStartAtMs = new Map<string, number>();
|
private readonly nextEmitStartAtMs = new Map<string, number>();
|
||||||
private readonly emitStartFailureCount = new Map<string, number>();
|
private readonly emitStartFailureCount = new Map<string, number>();
|
||||||
@@ -91,6 +102,20 @@ export class ItemEmitRuntime {
|
|||||||
const preserveSchedule = options?.preserveSchedule === true;
|
const preserveSchedule = options?.preserveSchedule === true;
|
||||||
const output = this.outputs.get(itemId);
|
const output = this.outputs.get(itemId);
|
||||||
if (output) {
|
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.pause();
|
||||||
output.element.removeEventListener('ended', output.onEnded);
|
output.element.removeEventListener('ended', output.onEnded);
|
||||||
output.element.src = '';
|
output.element.src = '';
|
||||||
@@ -162,6 +187,7 @@ export class ItemEmitRuntime {
|
|||||||
validIds.add(item.id);
|
validIds.add(item.id);
|
||||||
const existing = this.outputs.get(item.id);
|
const existing = this.outputs.get(item.id);
|
||||||
if (existing && existing.soundUrl === soundUrl) {
|
if (existing && existing.soundUrl === soundUrl) {
|
||||||
|
this.resumeStateByItemId.delete(item.id);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -196,6 +222,33 @@ export class ItemEmitRuntime {
|
|||||||
this.nextEmitStartAtMs.set(item.id, Date.now() + delaySeconds * 1000);
|
this.nextEmitStartAtMs.set(item.id, Date.now() + delaySeconds * 1000);
|
||||||
};
|
};
|
||||||
element.addEventListener('ended', onEnded);
|
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;
|
const destination = this.audio.getOutputDestinationNode() ?? audioCtx.destination;
|
||||||
if (this.audio.supportsStereoPanner()) {
|
if (this.audio.supportsStereoPanner()) {
|
||||||
panner = audioCtx.createStereoPanner();
|
panner = audioCtx.createStereoPanner();
|
||||||
@@ -216,6 +269,7 @@ export class ItemEmitRuntime {
|
|||||||
gain,
|
gain,
|
||||||
panner,
|
panner,
|
||||||
});
|
});
|
||||||
|
this.resumeStateByItemId.delete(item.id);
|
||||||
this.tryStartEmitPlayback(item.id, element);
|
this.tryStartEmitPlayback(item.id, element);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +284,11 @@ export class ItemEmitRuntime {
|
|||||||
this.nextEmitStartAtMs.delete(itemId);
|
this.nextEmitStartAtMs.delete(itemId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for (const itemId of Array.from(this.resumeStateByItemId.keys())) {
|
||||||
|
if (!seenItemIds.has(itemId)) {
|
||||||
|
this.resumeStateByItemId.delete(itemId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSpatialAudio(items: Map<string, WorldItem>, playerPosition: { x: number; y: number }): void {
|
updateSpatialAudio(items: Map<string, WorldItem>, playerPosition: { x: number; y: number }): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user