Normalize Dropbox URLs and add throttled stream restart retries
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.25 R233";
|
window.CHGRID_WEB_VERSION = "2026.02.25 R234";
|
||||||
// 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";
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const SUBSCRIBE_PRELOAD_SQUARES = 5;
|
|||||||
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
|
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
|
||||||
const SPATIAL_RAMP_SECONDS = 0.2;
|
const SPATIAL_RAMP_SECONDS = 0.2;
|
||||||
const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3;
|
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. */
|
/** Maps a 0-100 speed control to playback-rate range used by emitted audio. */
|
||||||
function resolveEmitPlaybackRate(raw: unknown): number {
|
function resolveEmitPlaybackRate(raw: unknown): number {
|
||||||
@@ -64,6 +65,8 @@ function resolveEmitRates(item: WorldItem): { playbackRate: number; preservePitc
|
|||||||
|
|
||||||
export class ItemEmitRuntime {
|
export class ItemEmitRuntime {
|
||||||
private readonly outputs = new Map<string, EmitOutput>();
|
private readonly outputs = new Map<string, EmitOutput>();
|
||||||
|
private readonly pendingEmitStarts = new Set<string>();
|
||||||
|
private readonly nextEmitStartAtMs = new Map<string, number>();
|
||||||
private layerEnabled = true;
|
private layerEnabled = true;
|
||||||
private listenerPositions: Array<{ x: number; y: number }> = [];
|
private listenerPositions: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
@@ -84,6 +87,8 @@ export class ItemEmitRuntime {
|
|||||||
output.gain.disconnect();
|
output.gain.disconnect();
|
||||||
output.panner?.disconnect();
|
output.panner?.disconnect();
|
||||||
this.outputs.delete(itemId);
|
this.outputs.delete(itemId);
|
||||||
|
this.pendingEmitStarts.delete(itemId);
|
||||||
|
this.nextEmitStartAtMs.delete(itemId);
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanupAll(): void {
|
cleanupAll(): void {
|
||||||
@@ -170,7 +175,7 @@ export class ItemEmitRuntime {
|
|||||||
gain.connect(destination);
|
gain.connect(destination);
|
||||||
}
|
}
|
||||||
this.outputs.set(item.id, { soundUrl, element, source, effectInput, effectRuntime, effect, effectValue, gain, panner });
|
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())) {
|
for (const itemId of Array.from(this.outputs.keys())) {
|
||||||
@@ -226,6 +231,7 @@ export class ItemEmitRuntime {
|
|||||||
const panValue = mix?.pan ?? 0;
|
const panValue = mix?.pan ?? 0;
|
||||||
const emitVolume = volumePercentToGain(item.params.emitVolume, 100);
|
const emitVolume = volumePercentToGain(item.params.emitVolume, 100);
|
||||||
output.gain.gain.setTargetAtTime(gainValue * emitVolume, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
output.gain.gain.setTargetAtTime(gainValue * emitVolume, audioCtx.currentTime, SPATIAL_TIME_CONSTANT_SECONDS);
|
||||||
|
this.tryStartEmitPlayback(itemId, output.element);
|
||||||
if (output.panner) {
|
if (output.panner) {
|
||||||
const resolvedPan = this.audio.getOutputMode() === 'mono' ? 0 : Math.max(-1, Math.min(1, panValue));
|
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);
|
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,
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,10 +167,13 @@ const SUBSCRIBE_PRELOAD_SQUARES = 5;
|
|||||||
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
|
const UNSUBSCRIBE_HYSTERESIS_SQUARES = 8;
|
||||||
const SPATIAL_RAMP_SECONDS = 0.2;
|
const SPATIAL_RAMP_SECONDS = 0.2;
|
||||||
const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3;
|
const SPATIAL_TIME_CONSTANT_SECONDS = SPATIAL_RAMP_SECONDS / 3;
|
||||||
|
const STREAM_PLAY_RETRY_MS = 5000;
|
||||||
|
|
||||||
export class RadioStationRuntime {
|
export class RadioStationRuntime {
|
||||||
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
|
private readonly sharedRadioSources = new Map<string, SharedRadioSource>();
|
||||||
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
|
private readonly itemRadioOutputs = new Map<string, ItemRadioOutput>();
|
||||||
|
private readonly pendingSharedStarts = new Set<string>();
|
||||||
|
private readonly nextSharedStartAtMs = new Map<string, number>();
|
||||||
private layerEnabled = true;
|
private layerEnabled = true;
|
||||||
private listenerPositions: Array<{ x: number; y: number }> = [];
|
private listenerPositions: Array<{ x: number; y: number }> = [];
|
||||||
|
|
||||||
@@ -279,6 +282,10 @@ export class RadioStationRuntime {
|
|||||||
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
output.gain.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 0.05);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const shared = this.sharedRadioSources.get(output.streamUrl);
|
||||||
|
if (shared) {
|
||||||
|
this.tryStartSharedPlayback(shared);
|
||||||
|
}
|
||||||
const spatialConfig = this.getSpatialConfig(item);
|
const spatialConfig = this.getSpatialConfig(item);
|
||||||
const mix = resolveSpatialMix({
|
const mix = resolveSpatialMix({
|
||||||
dx: item.x - playerPosition.x,
|
dx: item.x - playerPosition.x,
|
||||||
@@ -330,6 +337,8 @@ export class RadioStationRuntime {
|
|||||||
shared.element.src = '';
|
shared.element.src = '';
|
||||||
shared.source.disconnect();
|
shared.source.disconnect();
|
||||||
this.sharedRadioSources.delete(streamUrl);
|
this.sharedRadioSources.delete(streamUrl);
|
||||||
|
this.pendingSharedStarts.delete(streamUrl);
|
||||||
|
this.nextSharedStartAtMs.delete(streamUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getOrCreateSharedSource(streamUrl: string): SharedRadioSource | null {
|
private getOrCreateSharedSource(streamUrl: string): SharedRadioSource | null {
|
||||||
@@ -345,7 +354,6 @@ export class RadioStationRuntime {
|
|||||||
element.loop = true;
|
element.loop = true;
|
||||||
element.preload = 'none';
|
element.preload = 'none';
|
||||||
const source = audioCtx.createMediaElementSource(element);
|
const source = audioCtx.createMediaElementSource(element);
|
||||||
void element.play().catch(() => undefined);
|
|
||||||
const shared: SharedRadioSource = {
|
const shared: SharedRadioSource = {
|
||||||
streamUrl,
|
streamUrl,
|
||||||
element,
|
element,
|
||||||
@@ -353,9 +361,44 @@ export class RadioStationRuntime {
|
|||||||
refCount: 1,
|
refCount: 1,
|
||||||
};
|
};
|
||||||
this.sharedRadioSources.set(streamUrl, shared);
|
this.sharedRadioSources.set(streamUrl, shared);
|
||||||
|
this.tryStartSharedPlayback(shared);
|
||||||
return 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> {
|
private async ensureRuntime(item: WorldItem): Promise<void> {
|
||||||
const streamUrl = String(item.params.streamUrl ?? '').trim();
|
const streamUrl = String(item.params.streamUrl ?? '').trim();
|
||||||
if (!streamUrl) {
|
if (!streamUrl) {
|
||||||
|
|||||||
@@ -273,6 +273,49 @@ function resolve_redirect_url($baseUrl, $location)
|
|||||||
return $scheme . '://' . $host . $port . $dir . $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)
|
function resolve_safe_redirect_chain($initialUrl, $allowlistSuffixes, $requestHeaders, $maxRedirects, &$error)
|
||||||
{
|
{
|
||||||
$error = '';
|
$error = '';
|
||||||
@@ -351,6 +394,7 @@ $rawUrl = isset($_GET['url']) ? trim((string) $_GET['url']) : '';
|
|||||||
if ($rawUrl === '') {
|
if ($rawUrl === '') {
|
||||||
send_text(400, 'missing url query param');
|
send_text(400, 'missing url query param');
|
||||||
}
|
}
|
||||||
|
$rawUrl = normalize_dropbox_url($rawUrl);
|
||||||
|
|
||||||
// Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com
|
// Optional allowlist env var: CHGRID_MEDIA_PROXY_ALLOWLIST=dropbox.com,example.com
|
||||||
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
|
$allowlistEnv = getenv('CHGRID_MEDIA_PROXY_ALLOWLIST');
|
||||||
|
|||||||
Reference in New Issue
Block a user