Normalize Dropbox URLs and add throttled stream restart retries

This commit is contained in:
Jage9
2026-02-24 20:04:23 -05:00
parent c38778451d
commit 1609a297a2
4 changed files with 130 additions and 3 deletions

View File

@@ -29,6 +29,7 @@ const SUBSCRIBE_PRELOAD_SQUARES = 5;
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
const SPATIAL_RAMP_SECONDS = 0.2;
const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3;
const STREAM_PLAY_RETRY_MS = 5000;
/** Maps a 0-100 speed control to playback-rate range used by emitted audio. */
function resolveEmitPlaybackRate(raw: unknown): number {
@@ -64,6 +65,8 @@ function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitc
export class ItemEmitRuntime {
private readonly outputs = new Map<string, EmitOutput>();
private readonly pendingEmitStarts = new Set<string>();
private readonly nextEmitStartAtMs = new Map<string, number>();
private layerEnabled = true;
private listenerPositions: Array<{ x: number; y: number }> = [];
@@ -84,6 +87,8 @@ export class ItemEmitRuntime {
output.gain.disconnect();
output.panner?.disconnect();
this.outputs.delete(itemId);
this.pendingEmitStarts.delete(itemId);
this.nextEmitStartAtMs.delete(itemId);
}
cleanupAll(): void {
@@ -170,7 +175,7 @@ export class ItemEmitRuntime {
gain.connect(destination);
}
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner });
void element.play().catch(() => undefined);
this.tryStartEmitPlayback(item.id, element);
}
for (const itemId of Array.from(this.outputs.keys())) {
@@ -226,6 +231,7 @@ export class ItemEmitRuntime {
const panValue = mix?.pan ?? 0;
const emitVolume = volumePercentToGain(item.params.emitVolume, 100);
output.gain.gain.setTargetAtTime(gainValue * emitVolume, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
this.tryStartEmitPlayback(itemId, output.element);
if (output.panner) {
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
output.panner.pan.setTargetAtTime(resolvedPan, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
@@ -246,4 +252,38 @@ export class ItemEmitRuntime {
Math.hypot(item.x - listenerPosition.x, item.y - listenerPosition.y) <= threshold,
);
}
private tryStartEmitPlayback(itemId: string, element: HTMLAudioElement): void {
if (!element.paused) {
this.nextEmitStartAtMs.delete(itemId);
return;
}
if (this.pendingEmitStarts.has(itemId)) {
return;
}
const now = Date.now();
const retryAt = this.nextEmitStartAtMs.get(itemId) ?? 0;
if (now < retryAt) {
return;
}
this.pendingEmitStarts.add(itemId);
if (element.error) {
try {
element.load();
} catch {
// Ignore stale media reload failures.
}
}
void element
.play()
.then(() => {
this.nextEmitStartAtMs.delete(itemId);
})
.catch(() => {
this.nextEmitStartAtMs.set(itemId, Date.now() + STREAM_PLAY_RETRY_MS);
})
.finally(() => {
this.pendingEmitStarts.delete(itemId);
});
}
}

View File

@@ -167,10 +167,13 @@ const SUBSCRIBE_PRELOAD_SQUARES = 5;
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
const SPATIAL_RAMP_SECONDS = 0.2;
const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3;
const STREAM_PLAY_RETRY_MS = 5000;
export class RadioStationRuntime {
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
private readonly pendingSharedStarts = new Set<string>();
private readonly nextSharedStartAtMs = new Map<string, number>();
private layerEnabled = true;
private listenerPositions: Array<{ x: number; y: number }> = [];
@@ -279,6 +282,10 @@ export class RadioStationRuntime {
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
continue;
}
const shared = this.sharedRadioSources.get(output.streamUrl);
if (shared) {
this.tryStartSharedPlayback(shared);
}
const spatialConfig = this.getSpatialConfig(item);
const mix = resolveSpatialMix({
dx: item.x - playerPosition.x,
@@ -330,6 +337,8 @@ export class RadioStationRuntime {
shared.element.src = '';
shared.source.disconnect();
this.sharedRadioSources.delete(streamUrl);
this.pendingSharedStarts.delete(streamUrl);
this.nextSharedStartAtMs.delete(streamUrl);
}
private getOrCreateSharedSource(streamUrl: string): SharedRadioSource | null {
@@ -345,7 +354,6 @@ export class RadioStationRuntime {
element.loop = true;
element.preload = 'none';
const source = audioCtx.createMediaElementSource(element);
void element.play().catch(() => undefined);
const shared: SharedRadioSource = {
streamUrl,
element,
@@ -353,9 +361,44 @@ export class RadioStationRuntime {
refCount: 1,
};
this.sharedRadioSources.set(streamUrl, shared);
this.tryStartSharedPlayback(shared);
return shared;
}
private tryStartSharedPlayback(shared: SharedRadioSource): void {
if (!shared.element.paused) {
this.nextSharedStartAtMs.delete(shared.streamUrl);
return;
}
if (this.pendingSharedStarts.has(shared.streamUrl)) {
return;
}
const now = Date.now();
const retryAt = this.nextSharedStartAtMs.get(shared.streamUrl) ?? 0;
if (now < retryAt) {
return;
}
this.pendingSharedStarts.add(shared.streamUrl);
if (shared.element.error) {
try {
shared.element.load();
} catch {
// Ignore stale media reload failures.
}
}
void shared.element
.play()
.then(() => {
this.nextSharedStartAtMs.delete(shared.streamUrl);
})
.catch(() => {
this.nextSharedStartAtMs.set(shared.streamUrl, Date.now() + STREAM_PLAY_RETRY_MS);
})
.finally(() => {
this.pendingSharedStarts.delete(shared.streamUrl);
});
}
private async ensureRuntime(item: WorldItem): Promise<void> {
const streamUrl = String(item.params.streamUrl ?? '').trim();
if (!streamUrl) {