diff --git a/client/public/version.js b/client/public/version.js index 92bb4a2..033c289 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.25 R233"; +window.CHGRID_WEB_VERSION = "2026.02.25 R234"; // 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 a52b48f..be9efaf 100644 --- a/client/src/audio/itemEmitRuntime.ts +++ b/client/src/audio/itemEmitRuntime.ts @@ -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(); + private readonly pendingEmitStarts = new Set(); + private readonly nextEmitStartAtMs = new Map(); 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); + }); + } } diff --git a/client/src/audio/radioStationRuntime.ts b/client/src/audio/radioStationRuntime.ts index e1b644e..aba4293 100644 --- a/client/src/audio/radioStationRuntime.ts +++ b/client/src/audio/radioStationRuntime.ts @@ -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(); private readonly itemRadioOutputs = new Map(); + private readonly pendingSharedStarts = new Set(); + private readonly nextSharedStartAtMs = new Map(); 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 { const streamUrl = String(item.params.streamUrl ?? '').trim(); if (!streamUrl) { diff --git a/deploy/php/media_proxy.php b/deploy/php/media_proxy.php index ba60573..c82d47e 100644 --- a/deploy/php/media_proxy.php +++ b/deploy/php/media_proxy.php @@ -273,6 +273,49 @@ function resolve_redirect_url($baseUrl, $location) return $scheme . '://' . $host . $port . $dir . $location; } +function normalize_dropbox_url($url) +{ + $parts = parse_url($url); + if ($parts === false || !isset($parts['host'])) { + return $url; + } + $host = strtolower((string) $parts['host']); + if (!host_matches_suffix($host, 'dropbox.com')) { + return $url; + } + + $query = array(); + if (isset($parts['query']) && $parts['query'] !== '') { + parse_str((string) $parts['query'], $query); + } + // Dropbox direct media playback is most reliable with raw=1. + $query['raw'] = '1'; + unset($query['dl']); + + $scheme = isset($parts['scheme']) ? (string) $parts['scheme'] : 'https'; + $user = isset($parts['user']) ? (string) $parts['user'] : ''; + $pass = isset($parts['pass']) ? (string) $parts['pass'] : ''; + $auth = ''; + if ($user !== '') { + $auth = $user; + if ($pass !== '') { + $auth .= ':' . $pass; + } + $auth .= '@'; + } + $hostPort = (string) $parts['host']; + if (isset($parts['port'])) { + $hostPort .= ':' . (int) $parts['port']; + } + $path = isset($parts['path']) ? (string) $parts['path'] : '/'; + $fragment = isset($parts['fragment']) && $parts['fragment'] !== '' ? '#' . (string) $parts['fragment'] : ''; + $queryString = http_build_query($query); + if ($queryString !== '') { + $queryString = '?' . $queryString; + } + return $scheme . '://' . $auth . $hostPort . $path . $queryString . $fragment; +} + function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHeaders, $maxRedirects, &$error) { $error = ''; @@ -351,6 +394,7 @@ $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : ''; if ($rawUrl === '') { send_text(400, 'missing url query param'); } +$rawUrl = normalize_dropbox_url($rawUrl); // Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com $allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');