Initial commit
This commit is contained in:
33
static/admin.js
Normal file
33
static/admin.js
Normal file
@@ -0,0 +1,33 @@
|
||||
(() => {
|
||||
// Optional live updates on dashboard
|
||||
if (!document.getElementById('tbl-displays')) return;
|
||||
|
||||
const socket = io({ transports: ['websocket'], upgrade: false });
|
||||
socket.on('connect', () => {
|
||||
socket.emit('admin_join');
|
||||
});
|
||||
|
||||
function updateRow(publicId, patch) {
|
||||
const row = document.querySelector(`tr[data-public-id="${publicId}"]`);
|
||||
if (!row) return;
|
||||
if (patch.is_online !== undefined) row.querySelector('.st').textContent = patch.is_online ? 'online' : 'offline';
|
||||
if (patch.last_seen) row.querySelector('.ls').textContent = patch.last_seen;
|
||||
if (patch.latency_ms !== undefined) row.querySelector('.lat').textContent = patch.latency_ms ? `${patch.latency_ms.toFixed(1)}ms` : '';
|
||||
if (patch.offset_ms !== undefined) row.querySelector('.off').textContent = patch.offset_ms ? `${patch.offset_ms.toFixed(1)}ms` : '';
|
||||
}
|
||||
|
||||
socket.on('admin_snapshot', (msg) => {
|
||||
const live = msg.live || {};
|
||||
Object.keys(live).forEach(pid => updateRow(pid, { ...live[pid], is_online: true }));
|
||||
});
|
||||
|
||||
socket.on('admin_display_update', (msg) => {
|
||||
updateRow(msg.public_id, msg);
|
||||
});
|
||||
|
||||
socket.on('admin_event_triggered', (msg) => {
|
||||
const el = document.getElementById('active-event');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div><strong>${msg.event_name}</strong> (#${msg.event_id})</div><div>Start: <code>${msg.start_time_ms.toFixed(3)}</code></div>`;
|
||||
});
|
||||
})();
|
||||
536
static/display.js
Normal file
536
static/display.js
Normal file
@@ -0,0 +1,536 @@
|
||||
(() => {
|
||||
const cfgEl = document.getElementById('syncplayer-config');
|
||||
const cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {};
|
||||
const publicId = cfg.public_id;
|
||||
const video = document.getElementById('v');
|
||||
const dbg = document.getElementById('dbg');
|
||||
const idleImg = document.getElementById('idle');
|
||||
const idleUrl = cfg.idle_image_url;
|
||||
|
||||
let socket;
|
||||
let offsetMs = 0; // server_time = client_time + offsetMs
|
||||
let lastRttMs = 0;
|
||||
let bestRttMs = Infinity;
|
||||
let bestOffsetMs = 0;
|
||||
let syncBurstInProgress = false;
|
||||
let debugVisible = false;
|
||||
let lastSocketState = 'init';
|
||||
let lastError = '';
|
||||
let lastEventStart = null;
|
||||
let caching = {
|
||||
enabled: cfg.cache_enabled !== false,
|
||||
mode: cfg.cache_mode || 'range', // 'head' | 'range'
|
||||
rangeBytes: (typeof cfg.cache_range_bytes === 'number') ? cfg.cache_range_bytes : 1048576,
|
||||
maxConcurrent: 2,
|
||||
active: 0,
|
||||
queue: [],
|
||||
paused: false,
|
||||
started: false,
|
||||
done: 0,
|
||||
total: 0,
|
||||
lastCacheErr: '',
|
||||
};
|
||||
|
||||
if (!publicId) {
|
||||
lastError = 'missing public_id (check /display/<public_id> URL)';
|
||||
}
|
||||
|
||||
window.addEventListener('error', (e) => {
|
||||
// Capture JS errors that would otherwise halt connect() and leave the overlay stuck.
|
||||
const msg = (e && e.message) ? e.message : String(e || 'window_error');
|
||||
lastError = `js_error: ${msg}`;
|
||||
});
|
||||
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const msg = (e && e.reason && e.reason.message) ? e.reason.message : String((e && e.reason) || 'unhandledrejection');
|
||||
lastError = `promise_rejection: ${msg}`;
|
||||
});
|
||||
|
||||
// Active event state on client
|
||||
let active = {
|
||||
eventId: null,
|
||||
startTimeMs: null,
|
||||
videoUrl: null,
|
||||
isReady: false,
|
||||
hasStarted: false,
|
||||
};
|
||||
|
||||
function clearActive() {
|
||||
active.eventId = null;
|
||||
active.startTimeMs = null;
|
||||
active.videoUrl = null;
|
||||
active.isReady = false;
|
||||
active.hasStarted = false;
|
||||
}
|
||||
|
||||
function showIdleImage(show) {
|
||||
if (!idleImg) return;
|
||||
if (!idleUrl) {
|
||||
// If not configured, keep it hidden.
|
||||
idleImg.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
if (!idleImg.src) idleImg.src = idleUrl;
|
||||
idleImg.style.display = show ? 'block' : 'none';
|
||||
// When idle is visible, keep video element visually hidden (some platforms show last frame otherwise)
|
||||
if (video) video.style.visibility = show ? 'hidden' : 'visible';
|
||||
}
|
||||
|
||||
function isVideoActivelyPlaying() {
|
||||
if (!video) return false;
|
||||
if (video.readyState < 2) return false;
|
||||
if (video.ended) return false;
|
||||
if (video.paused) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function nowMs() {
|
||||
// performance.timeOrigin + performance.now gives a monotonic, high-res clock
|
||||
const origin = (typeof performance.timeOrigin === 'number') ? performance.timeOrigin : (Date.now() - performance.now());
|
||||
return origin + performance.now();
|
||||
}
|
||||
|
||||
function serverNowMs() {
|
||||
return nowMs() + offsetMs;
|
||||
}
|
||||
|
||||
function setOffsetSample(newOffset, rtt) {
|
||||
// Prefer the lowest-RTT sample (least asymmetric delay).
|
||||
// Also apply a bit of smoothing to avoid wild jumps.
|
||||
// rtt can briefly be negative due to clock resolution or scheduling jitter; ignore those.
|
||||
if (isFinite(rtt) && rtt > 0 && rtt < bestRttMs) {
|
||||
bestRttMs = rtt;
|
||||
bestOffsetMs = newOffset;
|
||||
}
|
||||
|
||||
// During a sync burst (connect/event), snap quickly to best sample.
|
||||
// Otherwise, slowly converge.
|
||||
const alpha = syncBurstInProgress ? 0.65 : 0.15;
|
||||
offsetMs = offsetMs * (1 - alpha) + bestOffsetMs * alpha;
|
||||
lastRttMs = rtt;
|
||||
}
|
||||
|
||||
function setDebug(text) {
|
||||
if (!debugVisible) return;
|
||||
dbg.textContent = text;
|
||||
}
|
||||
|
||||
function updateDebugOverlay() {
|
||||
if (!debugVisible) return;
|
||||
|
||||
const conn = socket && socket.connected;
|
||||
const now = serverNowMs();
|
||||
const ev = lastEventStart;
|
||||
const readyState = video ? video.readyState : -1;
|
||||
const paused = video ? video.paused : true;
|
||||
const ct = video ? video.currentTime : 0;
|
||||
|
||||
setDebug(
|
||||
`id=${publicId}\n` +
|
||||
`socket=${conn ? 'connected' : 'disconnected'} state=${lastSocketState}\n` +
|
||||
`offsetMs=${offsetMs.toFixed(2)} rttMs=${lastRttMs.toFixed(2)} bestRttMs=${isFinite(bestRttMs) ? bestRttMs.toFixed(2) : 'inf'}\n` +
|
||||
`serverNowMs=${now.toFixed(1)}\n` +
|
||||
`video: readyState=${readyState} paused=${paused} ct=${ct.toFixed(3)} rate=${(video.playbackRate||1).toFixed(3)}\n` +
|
||||
(ev ? `eventId=${ev.event_id} start=${ev.start_time_ms.toFixed(1)}\nurl=${ev.video_url}\n` : 'eventId=(none)\n') +
|
||||
(caching.enabled ? `cache: ${caching.done}/${caching.total} active=${caching.active} paused=${caching.paused}\n` : '') +
|
||||
(caching.lastCacheErr ? `cacheErr=${caching.lastCacheErr}\n` : '') +
|
||||
(lastError ? `err=${lastError}\n` : '')
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const res = await fetch(url, { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error(`http ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function warmOne(url) {
|
||||
// Best-effort cache warming. Range requests are widely supported and cheap.
|
||||
try {
|
||||
let headers = {};
|
||||
let method = 'GET';
|
||||
if (caching.mode === 'head') {
|
||||
method = 'HEAD';
|
||||
} else {
|
||||
headers['Range'] = `bytes=0-${Math.max(0, caching.rangeBytes - 1)}`;
|
||||
}
|
||||
const res = await fetch(url, { method, headers, cache: 'force-cache' });
|
||||
|
||||
// If the server ignores Range and returns 200, do NOT download the full video.
|
||||
// Treat it as "warmed enough" (the request itself may prime TCP + DNS).
|
||||
if (method === 'GET') {
|
||||
if (res.status === 206) {
|
||||
try { await res.arrayBuffer(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (!res.ok && res.status !== 206 && res.status !== 200) {
|
||||
throw new Error(`warm status=${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
caching.lastCacheErr = String(e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
function cachePump() {
|
||||
if (!caching.enabled || caching.paused) return;
|
||||
while (caching.active < caching.maxConcurrent && caching.queue.length) {
|
||||
const url = caching.queue.shift();
|
||||
caching.active++;
|
||||
warmOne(url).finally(() => {
|
||||
caching.active--;
|
||||
caching.done++;
|
||||
cachePump();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function startCacheWarmup() {
|
||||
if (!caching.enabled || caching.started) return;
|
||||
caching.started = true;
|
||||
try {
|
||||
const data = await fetchJson('/api/videos');
|
||||
const vids = (data && data.videos) ? data.videos : [];
|
||||
caching.queue = vids.map(v => v.url);
|
||||
caching.total = caching.queue.length;
|
||||
cachePump();
|
||||
} catch (e) {
|
||||
caching.lastCacheErr = String(e && e.message ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
function connect() {
|
||||
if (typeof io !== 'function') {
|
||||
lastSocketState = 'no_io';
|
||||
lastError = 'Socket.IO client not loaded (check /socket.io/socket.io.js)';
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefer websocket, but allow polling fallback (important for some environments / server async modes).
|
||||
socket = io({ transports: ['websocket', 'polling'], upgrade: true, reconnection: true, reconnectionDelayMax: 1000 });
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
lastSocketState = 'connect_error';
|
||||
lastError = (err && err.message) ? err.message : String(err || 'connect_error');
|
||||
console.warn('socket connect_error', err);
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
lastSocketState = 'socket_error';
|
||||
lastError = (err && err.error) ? err.error : String(err || 'socket_error');
|
||||
console.warn('socket error', err);
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
lastSocketState = 'connected';
|
||||
lastError = '';
|
||||
socket.emit('hello', { public_id: publicId });
|
||||
startTimeSyncLoop();
|
||||
startHeartbeatLoop();
|
||||
// Quickly converge on a good offset right after connecting.
|
||||
// This reduces the chance we schedule the next event using a bad clock estimate.
|
||||
timeSyncBurst(8, 120);
|
||||
socket.emit('request_state', { public_id: publicId });
|
||||
});
|
||||
|
||||
socket.on('hello_ack', () => {
|
||||
lastSocketState = 'hello_ack';
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
lastSocketState = 'disconnected';
|
||||
active.isReady = false;
|
||||
// If we lose state, prefer showing idle image over a black frame.
|
||||
showIdleImage(true);
|
||||
});
|
||||
|
||||
socket.on('time_sync', (msg) => {
|
||||
// msg: {t1_ms, t2_ms, t3_ms}; we receive at t4
|
||||
// NTP formulas:
|
||||
// rtt = (t4 - t1) - (t3 - t2)
|
||||
// offset = ((t2 - t1) + (t3 - t4)) / 2
|
||||
const t4 = nowMs();
|
||||
const t1 = msg.t1_ms;
|
||||
const t2 = msg.t2_ms;
|
||||
const t3 = msg.t3_ms;
|
||||
|
||||
if (!isFinite(t1) || !isFinite(t2) || !isFinite(t3)) return;
|
||||
const rtt = (t4 - t1) - (t3 - t2);
|
||||
const newOffset = ((t2 - t1) + (t3 - t4)) / 2.0;
|
||||
|
||||
setOffsetSample(newOffset, rtt);
|
||||
});
|
||||
|
||||
socket.on('event_start', async (msg) => {
|
||||
caching.paused = true; // don't contend bandwidth at synchronized start
|
||||
lastEventStart = msg;
|
||||
// msg: {event_id, video_url, start_time_ms}
|
||||
active.eventId = msg.event_id;
|
||||
active.startTimeMs = msg.start_time_ms;
|
||||
active.videoUrl = msg.video_url;
|
||||
active.isReady = false;
|
||||
active.hasStarted = false;
|
||||
|
||||
// Any incoming event means something will play; hide idle image.
|
||||
showIdleImage(false);
|
||||
|
||||
// A trigger may arrive while our offset is still converging.
|
||||
// Do a quick burst to get a good offset before scheduling playback.
|
||||
await timeSyncBurst(8, 120);
|
||||
await prepareVideo(msg.video_url);
|
||||
scheduleStart();
|
||||
});
|
||||
|
||||
socket.on('event_state', async (msg) => {
|
||||
if (!msg.active) return;
|
||||
caching.paused = true;
|
||||
lastEventStart = msg;
|
||||
active.eventId = msg.event_id;
|
||||
active.startTimeMs = msg.start_time_ms;
|
||||
active.videoUrl = msg.video_url;
|
||||
active.isReady = false;
|
||||
active.hasStarted = false;
|
||||
|
||||
showIdleImage(false);
|
||||
await prepareVideo(msg.video_url);
|
||||
|
||||
// Late join: seek to expected position then play.
|
||||
const expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0);
|
||||
try { video.currentTime = expectedSec; } catch (e) {}
|
||||
// Start immediately (but still apply drift correction)
|
||||
try { await video.play(); } catch (e) {}
|
||||
active.hasStarted = true;
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareVideo(url) {
|
||||
return new Promise((resolve) => {
|
||||
// Force new load
|
||||
video.pause();
|
||||
video.playbackRate = 1.0;
|
||||
video.src = url;
|
||||
video.load();
|
||||
|
||||
// We expect video to start soon; make sure it's visible
|
||||
if (video) video.style.visibility = 'visible';
|
||||
|
||||
video.onerror = () => {
|
||||
const err = video.error;
|
||||
lastError = err ? `media_error code=${err.code}` : 'media_error';
|
||||
console.warn('video error', err);
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
video.removeEventListener('canplaythrough', onReady);
|
||||
video.removeEventListener('canplay', onReady);
|
||||
video.removeEventListener('loadeddata', onReady);
|
||||
};
|
||||
|
||||
const onReady = () => {
|
||||
cleanup();
|
||||
// Pre-roll: ensure at t=0 and paused
|
||||
try { video.currentTime = 0; } catch (e) {}
|
||||
video.pause();
|
||||
active.isReady = true;
|
||||
resolve();
|
||||
};
|
||||
|
||||
// Some embedded browsers never fire canplaythrough reliably. Accept earlier readiness signals.
|
||||
video.addEventListener('canplaythrough', onReady);
|
||||
video.addEventListener('canplay', onReady);
|
||||
video.addEventListener('loadeddata', onReady);
|
||||
|
||||
// Safety timeout
|
||||
setTimeout(() => {
|
||||
if (!active.isReady) onReady();
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleStart() {
|
||||
if (!active.startTimeMs || !active.isReady || active.hasStarted) return;
|
||||
|
||||
const tick = async () => {
|
||||
const delta = active.startTimeMs - serverNowMs();
|
||||
if (delta <= 0) {
|
||||
try {
|
||||
// Always align to the expected timeline position before starting.
|
||||
// This avoids the “starts at 0 then fixes later” effect.
|
||||
let expectedSec = Math.max(0, (serverNowMs() - active.startTimeMs) / 1000.0);
|
||||
try { video.currentTime = expectedSec; } catch (e) {}
|
||||
|
||||
await video.play();
|
||||
active.hasStarted = true;
|
||||
} catch (e) {
|
||||
// autoplay policies: should work because muted
|
||||
// retry quickly
|
||||
setTimeout(tick, 50);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Use rAF for sub-frame precision as we approach start
|
||||
if (delta < 50) {
|
||||
requestAnimationFrame(tick);
|
||||
} else {
|
||||
setTimeout(tick, Math.min(250, delta - 25));
|
||||
}
|
||||
};
|
||||
tick();
|
||||
}
|
||||
|
||||
function startTimeSyncLoop() {
|
||||
// perform sync every 10 seconds
|
||||
const doSync = () => {
|
||||
if (!socket || !socket.connected) return;
|
||||
const t1 = nowMs();
|
||||
socket.emit('time_sync', { t1_ms: t1 });
|
||||
};
|
||||
doSync();
|
||||
setInterval(doSync, 10000);
|
||||
}
|
||||
|
||||
async function timeSyncBurst(samples = 6, spacingMs = 120) {
|
||||
if (!socket || !socket.connected) return;
|
||||
if (syncBurstInProgress) return;
|
||||
|
||||
syncBurstInProgress = true;
|
||||
bestRttMs = Infinity;
|
||||
|
||||
// Fire a small burst of NTP-like pings and use the best RTT sample.
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const t1 = nowMs();
|
||||
socket.emit('time_sync', { t1_ms: t1 });
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise(r => setTimeout(r, spacingMs));
|
||||
}
|
||||
|
||||
// Let the last replies land.
|
||||
await new Promise(r => setTimeout(r, spacingMs));
|
||||
syncBurstInProgress = false;
|
||||
}
|
||||
|
||||
function startHeartbeatLoop() {
|
||||
setInterval(() => {
|
||||
if (!socket || !socket.connected) return;
|
||||
socket.emit('heartbeat', {
|
||||
public_id: publicId,
|
||||
// NTP RTT estimate (ms)
|
||||
latency_ms: lastRttMs,
|
||||
offset_ms: offsetMs,
|
||||
ready: !!active.isReady,
|
||||
});
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
function startDriftCorrection() {
|
||||
setInterval(() => {
|
||||
if (!active.hasStarted || !active.startTimeMs) return;
|
||||
if (video.readyState < 2) return;
|
||||
if (video.paused) return;
|
||||
|
||||
let expectedSec = (serverNowMs() - active.startTimeMs) / 1000.0;
|
||||
const actualSec = video.currentTime;
|
||||
const driftSec = actualSec - expectedSec; // + means video ahead
|
||||
const driftMs = driftSec * 1000.0;
|
||||
|
||||
// Apply correction
|
||||
if (Math.abs(driftMs) > 100) {
|
||||
// Hard correction
|
||||
try { video.currentTime = Math.max(0, expectedSec); } catch (e) {}
|
||||
video.playbackRate = 1.0;
|
||||
} else if (Math.abs(driftMs) > 15) {
|
||||
// Soft correction via playbackRate
|
||||
if (driftMs > 0) {
|
||||
// ahead -> slow down
|
||||
video.playbackRate = 0.99;
|
||||
} else {
|
||||
// behind -> speed up
|
||||
video.playbackRate = 1.01;
|
||||
}
|
||||
} else {
|
||||
// close enough
|
||||
video.playbackRate = 1.0;
|
||||
}
|
||||
|
||||
setDebug(
|
||||
`id=${publicId}\n` +
|
||||
`serverNowMs=${serverNowMs().toFixed(3)}\n` +
|
||||
`offsetMs=${offsetMs.toFixed(3)} rttMs=${lastRttMs.toFixed(3)}\n` +
|
||||
`eventId=${active.eventId} start=${active.startTimeMs}\n` +
|
||||
`expected=${expectedSec.toFixed(3)} actual=${actualSec.toFixed(3)} driftMs=${driftMs.toFixed(2)}\n` +
|
||||
`rate=${video.playbackRate.toFixed(3)} state=${video.readyState}`
|
||||
);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
video.addEventListener('ended', () => {
|
||||
if (socket && socket.connected && active.eventId) {
|
||||
socket.emit('playback_ended', { public_id: publicId, event_id: active.eventId });
|
||||
}
|
||||
|
||||
// After a trigger ends, show idle until we receive a new event.
|
||||
clearActive();
|
||||
showIdleImage(true);
|
||||
});
|
||||
|
||||
// If we ever have no active playback, show idle image.
|
||||
video.addEventListener('pause', () => {
|
||||
// If video isn't actively playing and we don't expect immediate play, show idle.
|
||||
// Don't show idle during pre-roll scheduling (active.videoUrl set but not started yet).
|
||||
if (!isVideoActivelyPlaying() && !active.hasStarted) return;
|
||||
if (!isVideoActivelyPlaying()) showIdleImage(true);
|
||||
});
|
||||
|
||||
video.addEventListener('playing', () => {
|
||||
showIdleImage(false);
|
||||
});
|
||||
|
||||
video.addEventListener('error', () => {
|
||||
// Show idle on fatal errors to avoid black screen.
|
||||
showIdleImage(true);
|
||||
});
|
||||
|
||||
// Debug overlay toggle: Ctrl+D
|
||||
function toggleDebug() {
|
||||
debugVisible = !debugVisible;
|
||||
dbg.style.display = debugVisible ? 'block' : 'none';
|
||||
if (debugVisible) {
|
||||
setDebug(`id=${publicId}\n(debug enabled)\nconnecting...`);
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(updateDebugOverlay, 500);
|
||||
|
||||
// Many Chromium-based browsers reserve Ctrl+D (bookmark). Use F2 or Ctrl+Shift+D.
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
toggleDebug();
|
||||
return;
|
||||
}
|
||||
if (e.ctrlKey && e.shiftKey && (e.key === 'd' || e.key === 'D')) {
|
||||
e.preventDefault();
|
||||
toggleDebug();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Fallback for kiosk remotes: double click anywhere toggles.
|
||||
window.addEventListener('dblclick', (e) => {
|
||||
e.preventDefault();
|
||||
toggleDebug();
|
||||
});
|
||||
|
||||
// Prevent context menu / accidental UI
|
||||
window.addEventListener('contextmenu', (e) => e.preventDefault());
|
||||
|
||||
connect();
|
||||
startDriftCorrection();
|
||||
// Start cache warming shortly after page load.
|
||||
setTimeout(startCacheWarmup, 500);
|
||||
|
||||
// Initial state: show idle image until we get an event_state/event_start.
|
||||
showIdleImage(true);
|
||||
})();
|
||||
7
static/vendor/socket.io.min.js
generated
vendored
Normal file
7
static/vendor/socket.io.min.js
generated
vendored
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user