generated from Grigo/AndroidTemplate
fix
This commit is contained in:
Binary file not shown.
Binary file not shown.
+27
-12
@@ -15,6 +15,8 @@ WEB_SENDER_ID = "web"
|
||||
COMMAND_KINDS = frozenset({"at", "mode", "stats_push"})
|
||||
PAIRED_ONLINE_SEC = 30.0
|
||||
PAIRED_START_DELAY_SEC = 3.0
|
||||
# Hide devices on map/UI after this many seconds without telemetry.
|
||||
DEVICE_VISIBLE_SEC = 180.0
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -163,8 +165,13 @@ def list_devices() -> list[dict[str, Any]]:
|
||||
ORDER BY d.last_seen DESC
|
||||
"""
|
||||
).fetchall()
|
||||
cutoff = time.time() - DEVICE_VISIBLE_SEC
|
||||
devices = [_row_to_device(r) for r in rows]
|
||||
return [d for d in devices if not _is_null_island(d)]
|
||||
return [
|
||||
d
|
||||
for d in devices
|
||||
if not _is_null_island(d) and d.get("last_seen", 0) >= cutoff
|
||||
]
|
||||
|
||||
|
||||
def _is_null_island(device: dict[str, Any]) -> bool:
|
||||
@@ -335,13 +342,18 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
|
||||
WHERE p.track_id = t.id AND p.role IS NOT NULL AND p.role != ''
|
||||
ORDER BY p.ts DESC LIMIT 1)
|
||||
"""
|
||||
if device_id:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
track_cols = f"""
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
d.label AS device_label,
|
||||
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
|
||||
{role_sub} AS role
|
||||
FROM tracks t
|
||||
LEFT JOIN devices d ON d.device_id = t.device_id
|
||||
"""
|
||||
if device_id:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
{track_cols}
|
||||
WHERE t.device_id = ?
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
@@ -351,10 +363,7 @@ def list_tracks(device_id: Optional[str] = None, limit: int = 50) -> list[dict[s
|
||||
else:
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
(SELECT COUNT(*) FROM track_points p WHERE p.track_id = t.id) AS point_count,
|
||||
{role_sub} AS role
|
||||
FROM tracks t
|
||||
{track_cols}
|
||||
ORDER BY t.started_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -367,7 +376,11 @@ def get_track(track_id: int) -> dict[str, Any]:
|
||||
with _db() as conn:
|
||||
track = conn.execute(
|
||||
"""
|
||||
SELECT id, device_id, started_at, ended_at, label FROM tracks WHERE id = ?
|
||||
SELECT t.id, t.device_id, t.started_at, t.ended_at, t.label,
|
||||
d.label AS device_label
|
||||
FROM tracks t
|
||||
LEFT JOIN devices d ON d.device_id = t.device_id
|
||||
WHERE t.id = ?
|
||||
""",
|
||||
(track_id,),
|
||||
).fetchone()
|
||||
@@ -408,9 +421,11 @@ def get_chat(since: float = 0.0, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, device_id, text, ts FROM chat
|
||||
WHERE ts > ?
|
||||
ORDER BY ts ASC LIMIT ?
|
||||
SELECT c.id, c.device_id, c.text, c.ts, d.label AS device_label
|
||||
FROM chat c
|
||||
LEFT JOIN devices d ON d.device_id = c.device_id
|
||||
WHERE c.ts > ?
|
||||
ORDER BY c.ts ASC LIMIT ?
|
||||
""",
|
||||
(since, limit),
|
||||
).fetchall()
|
||||
|
||||
+137
-58
@@ -390,6 +390,8 @@
|
||||
let dualTracksActive = false;
|
||||
let singleTrackActive = false;
|
||||
let lastDevices = [];
|
||||
const deviceLabelCache = {};
|
||||
let timelineSpanMs = 1000;
|
||||
let elevProfileTx = null;
|
||||
let elevProfileRx = null;
|
||||
let elevProfileSingle = null;
|
||||
@@ -433,17 +435,61 @@
|
||||
return Math.abs(lat) < 1e-5 && Math.abs(lon) < 1e-5;
|
||||
}
|
||||
|
||||
function rememberDeviceLabels(devices) {
|
||||
(devices || []).forEach(d => {
|
||||
const lbl = d.device_label || d.label;
|
||||
if (lbl && lbl !== d.device_id) deviceLabelCache[d.device_id] = lbl;
|
||||
});
|
||||
}
|
||||
|
||||
function deviceDisplayName(d) {
|
||||
if (!d) return '—';
|
||||
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === d) : d;
|
||||
if (typeof d === 'object') {
|
||||
const direct = d.device_label || d.label;
|
||||
if (direct && direct !== d.device_id) return direct;
|
||||
}
|
||||
const id = typeof d === 'string' ? d : d.device_id;
|
||||
const label = dev?.label;
|
||||
if (deviceLabelCache[id]) return deviceLabelCache[id];
|
||||
const dev = typeof d === 'string' ? lastDevices.find(x => x.device_id === id) : d;
|
||||
const label = dev?.device_label || dev?.label;
|
||||
if (label && label !== id) return label;
|
||||
return id || '—';
|
||||
}
|
||||
|
||||
function sliderTime() {
|
||||
return overlapMin + parseFloat(document.getElementById('timeSlider').value || '0');
|
||||
const ms = parseInt(document.getElementById('timeSlider').value || '0', 10);
|
||||
return overlapMin + ms / 1000;
|
||||
}
|
||||
|
||||
function normalizeTrack(track) {
|
||||
if (!track?.points?.length) return track;
|
||||
const points = track.points.map(p => ({
|
||||
...p,
|
||||
ts: Number(p.ts),
|
||||
lat: Number(p.lat),
|
||||
lon: Number(p.lon),
|
||||
}));
|
||||
const maxTs = Math.max(...points.map(p => p.ts));
|
||||
if (maxTs > 1e11) {
|
||||
points.forEach(p => { p.ts /= 1000; });
|
||||
}
|
||||
points.sort((a, b) => a.ts - b.ts);
|
||||
return { ...track, points };
|
||||
}
|
||||
|
||||
function snapAtTime(track, telemetryRows, t, roleFallback) {
|
||||
const tel = telemetryAtTime(telemetryRows, t);
|
||||
if (tel) return tel;
|
||||
const pos = positionAt(track?.points, t);
|
||||
if (!pos) return null;
|
||||
return {
|
||||
meta: pos.meta,
|
||||
role: roleFallback || track?.role,
|
||||
rssi: pos.rssi,
|
||||
ts: t,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon
|
||||
};
|
||||
}
|
||||
|
||||
function rxQualityFromMeta(meta) {
|
||||
@@ -454,7 +500,7 @@
|
||||
|
||||
function qualityColor(pct) {
|
||||
if (pct == null || Number.isNaN(pct)) return null;
|
||||
const p = Math.max(0, Math.min(100, pct));
|
||||
const p = Math.max(0, Math.min(100, Number(pct)));
|
||||
const r = p < 50 ? Math.round(255 * (p / 50)) : 255;
|
||||
const g = p < 50 ? 255 : Math.round(255 * (1 - (p - 50) / 50));
|
||||
return `rgb(${r},${g},0)`;
|
||||
@@ -1635,34 +1681,40 @@
|
||||
/* --- Track helpers --- */
|
||||
function positionAt(points, t) {
|
||||
if (!points || !points.length) return null;
|
||||
const tNum = Number(t);
|
||||
const first = points[0];
|
||||
const last = points[points.length - 1];
|
||||
if (t <= first.ts) {
|
||||
return { lat: first.lat, lon: first.lon, meta: first.meta, rssi: first.rssi };
|
||||
const t0 = Number(first.ts);
|
||||
const t1 = Number(last.ts);
|
||||
if (tNum <= t0) {
|
||||
return { lat: Number(first.lat), lon: Number(first.lon), meta: first.meta, rssi: first.rssi };
|
||||
}
|
||||
if (t >= last.ts) {
|
||||
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
|
||||
if (tNum >= t1) {
|
||||
return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
|
||||
}
|
||||
for (let i = 0; i < points.length - 1; i++) {
|
||||
const a = points[i];
|
||||
const b = points[i + 1];
|
||||
if (t >= a.ts && t <= b.ts) {
|
||||
const f = (t - a.ts) / (b.ts - a.ts);
|
||||
const ta = Number(a.ts);
|
||||
const tb = Number(b.ts);
|
||||
if (tNum >= ta && tNum <= tb) {
|
||||
const span = Math.max(tb - ta, 1e-9);
|
||||
const f = (tNum - ta) / span;
|
||||
return {
|
||||
lat: a.lat + (b.lat - a.lat) * f,
|
||||
lon: a.lon + (b.lon - a.lon) * f,
|
||||
meta: t - a.ts < b.ts - t ? a.meta : b.meta,
|
||||
rssi: t - a.ts < b.ts - t ? a.rssi : b.rssi
|
||||
lat: Number(a.lat) + (Number(b.lat) - Number(a.lat)) * f,
|
||||
lon: Number(a.lon) + (Number(b.lon) - Number(a.lon)) * f,
|
||||
meta: tNum - ta < tb - tNum ? a.meta : b.meta,
|
||||
rssi: tNum - ta < tb - tNum ? a.rssi : b.rssi
|
||||
};
|
||||
}
|
||||
}
|
||||
return { lat: last.lat, lon: last.lon, meta: last.meta, rssi: last.rssi };
|
||||
return { lat: Number(last.lat), lon: Number(last.lon), meta: last.meta, rssi: last.rssi };
|
||||
}
|
||||
|
||||
function overlapRange(txPts, rxPts) {
|
||||
if (!txPts.length || !rxPts.length) return null;
|
||||
const min = Math.max(txPts[0].ts, rxPts[0].ts);
|
||||
const max = Math.min(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
|
||||
const min = Math.max(Number(txPts[0].ts), Number(rxPts[0].ts));
|
||||
const max = Math.min(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
|
||||
if (min >= max) return null;
|
||||
return { min, max, mode: 'overlap' };
|
||||
}
|
||||
@@ -1672,8 +1724,8 @@
|
||||
if (!txPts.length || !rxPts.length) return null;
|
||||
const overlap = overlapRange(txPts, rxPts);
|
||||
if (overlap) return overlap;
|
||||
const min = Math.min(txPts[0].ts, rxPts[0].ts);
|
||||
const max = Math.max(txPts[txPts.length - 1].ts, rxPts[rxPts.length - 1].ts);
|
||||
const min = Math.min(Number(txPts[0].ts), Number(rxPts[0].ts));
|
||||
const max = Math.max(Number(txPts[txPts.length - 1].ts), Number(rxPts[rxPts.length - 1].ts));
|
||||
if (min >= max) return null;
|
||||
return { min, max, mode: 'union' };
|
||||
}
|
||||
@@ -1802,7 +1854,7 @@
|
||||
const qa = rxQualityFromMeta(a.meta);
|
||||
const qb = rxQualityFromMeta(b.meta);
|
||||
const q = qa != null && qb != null ? (qa + qb) / 2 : (qa ?? qb);
|
||||
const segColor = useQuality && q != null ? (qualityColor(q) || color) : color;
|
||||
const segColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
|
||||
const seg = L.polyline([[a.lat, a.lon], [b.lat, b.lon]], {
|
||||
color: segColor, weight: 4, opacity: 0.85
|
||||
}).addTo(map);
|
||||
@@ -1811,16 +1863,16 @@
|
||||
|
||||
pts.forEach(p => {
|
||||
const q = rxQualityFromMeta(p.meta);
|
||||
const ptColor = useQuality && q != null ? (qualityColor(q) || color) : color;
|
||||
const ptColor = useQuality && q != null ? (qualityColor(q) || '#ff8800') : color;
|
||||
const m = L.circleMarker([p.lat, p.lon], {
|
||||
radius: 3, color: ptColor, fillColor: ptColor, fillOpacity: 0.8
|
||||
});
|
||||
m.addTo(map);
|
||||
m.on('click', () => {
|
||||
const rel = Math.max(0, Math.min(p.ts - overlapMin, parseFloat(document.getElementById('timeSlider').max)));
|
||||
document.getElementById('timeSlider').value = String(rel);
|
||||
const relMs = Math.max(0, Math.min(Math.round((p.ts - overlapMin) * 1000), timelineSpanMs));
|
||||
document.getElementById('timeSlider').value = String(relMs);
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(p.ts, { openModal: true });
|
||||
updateTimelineAt(Number(p.ts), { openModal: true });
|
||||
});
|
||||
markerList.push(m);
|
||||
});
|
||||
@@ -1846,7 +1898,7 @@
|
||||
|
||||
function singleTrackRange(points) {
|
||||
if (!points || !points.length) return null;
|
||||
return { min: points[0].ts, max: points[points.length - 1].ts, mode: 'single' };
|
||||
return { min: Number(points[0].ts), max: Number(points[points.length - 1].ts), mode: 'single' };
|
||||
}
|
||||
|
||||
function updateTimelineAt(t, opts) {
|
||||
@@ -1922,7 +1974,7 @@
|
||||
}).addTo(map);
|
||||
let html = `<b>${new Date(t * 1000).toLocaleTimeString()}</b><br>`;
|
||||
html += `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}<br>`;
|
||||
const tel = nearestTelemetry(telemetrySingle, t);
|
||||
const tel = snapAtTime(track, telemetrySingle, t, track.role);
|
||||
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(pos.meta);
|
||||
html += RadioUI.formatRadioPanel(snap, new Set(), isRadioStaticOpen(mapModalBody));
|
||||
if (tel) html += '<br>' + formatTelemetryRow(tel, new Set());
|
||||
@@ -1930,7 +1982,7 @@
|
||||
openMapModal(html, 'timeline');
|
||||
}
|
||||
}
|
||||
const tel = nearestTelemetry(telemetrySingle, t);
|
||||
const tel = snapAtTime(track, telemetrySingle, t, track.role);
|
||||
const snap = tel ? telemetryToSnap(tel) : RadioUI.parseRadioSnapshot(null);
|
||||
const timelineStatsEl = document.getElementById('timelineStats');
|
||||
setPanelHtml(
|
||||
@@ -1960,14 +2012,16 @@
|
||||
const note = document.getElementById('timelineNote');
|
||||
overlapMin = range.min;
|
||||
overlapMax = range.max;
|
||||
const span = Math.max(0.1, overlapMax - overlapMin);
|
||||
const spanSec = Math.max(0.001, overlapMax - overlapMin);
|
||||
timelineSpanMs = Math.max(1, Math.round(spanSec * 1000));
|
||||
const slider = document.getElementById('timeSlider');
|
||||
slider.min = 0;
|
||||
slider.max = String(span);
|
||||
slider.step = span > 300 ? '1' : (span > 60 ? '0.5' : '0.1');
|
||||
slider.max = String(timelineSpanMs);
|
||||
slider.step = timelineSpanMs > 300000 ? 1000 : (timelineSpanMs > 60000 ? 500 : 100);
|
||||
slider.value = '0';
|
||||
document.getElementById('timeStart').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||||
document.getElementById('timeEnd').textContent = new Date(overlapMax * 1000).toLocaleTimeString();
|
||||
document.getElementById('timeCurrent').textContent = new Date(overlapMin * 1000).toLocaleTimeString();
|
||||
if (noteText != null) note.textContent = noteText;
|
||||
}
|
||||
|
||||
@@ -2033,7 +2087,7 @@
|
||||
function trackOptionLabel(t) {
|
||||
const start = new Date(t.started_at * 1000).toLocaleString();
|
||||
const role = t.role ? ` · ${t.role}` : '';
|
||||
const dev = t.device_id ? ` · ${deviceDisplayName(t.device_id)}` : '';
|
||||
const dev = t.device_id ? ` · ${deviceDisplayName(t)}` : '';
|
||||
return `#${t.id}${role}${dev} · ${start} (${t.point_count})`;
|
||||
}
|
||||
|
||||
@@ -2047,6 +2101,7 @@
|
||||
const res = await fetch('/api/tracks?limit=100', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('tracks ' + res.status);
|
||||
const tracks = await res.json();
|
||||
rememberDeviceLabels(tracks);
|
||||
const fill = (sel, hint) => {
|
||||
sel.innerHTML = `<option value="">${hint}</option>`;
|
||||
tracks.forEach(t => {
|
||||
@@ -2079,9 +2134,14 @@
|
||||
singleTrackActive = false;
|
||||
loadedTxTrack = null;
|
||||
loadedRxTrack = null;
|
||||
if (playTimer) { clearInterval(playTimer); playTimer = null; }
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||||
}
|
||||
const res = await fetch(`/api/tracks/${id}`, { cache: 'no-store' });
|
||||
loadedSingleTrack = await res.json();
|
||||
loadedSingleTrack = normalizeTrack(await res.json());
|
||||
rememberDeviceLabels([loadedSingleTrack]);
|
||||
if (!loadedSingleTrack.role && loadedSingleTrack.points) {
|
||||
const p = loadedSingleTrack.points.find(x => x.role);
|
||||
if (p) loadedSingleTrack.role = p.role;
|
||||
@@ -2131,14 +2191,18 @@
|
||||
clearTrackLayers();
|
||||
singleTrackActive = false;
|
||||
loadedSingleTrack = null;
|
||||
if (playTimer) { clearInterval(playTimer); playTimer = null; }
|
||||
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||||
}
|
||||
const [txRes, rxRes] = await Promise.all([
|
||||
fetch(`/api/tracks/${txId}`),
|
||||
fetch(`/api/tracks/${rxId}`)
|
||||
]);
|
||||
loadedTxTrack = await txRes.json();
|
||||
loadedRxTrack = await rxRes.json();
|
||||
loadedTxTrack = normalizeTrack(await txRes.json());
|
||||
loadedRxTrack = normalizeTrack(await rxRes.json());
|
||||
rememberDeviceLabels([loadedTxTrack, loadedRxTrack]);
|
||||
if (!loadedTxTrack.points?.length || !loadedRxTrack.points?.length) {
|
||||
document.getElementById('trackInfo').textContent = 'Пустой трек';
|
||||
return;
|
||||
@@ -2313,27 +2377,34 @@
|
||||
}
|
||||
refreshPairedStatus();
|
||||
};
|
||||
document.getElementById('timeSlider').oninput = e => {
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(overlapMin + parseFloat(e.target.value), { openModal: true });
|
||||
};
|
||||
document.getElementById('btnPlay').onclick = () => {
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||||
return;
|
||||
}
|
||||
function setupTimelineControls() {
|
||||
const slider = document.getElementById('timeSlider');
|
||||
document.getElementById('btnPlay').textContent = '⏸ Pause';
|
||||
playTimer = setInterval(() => {
|
||||
const step = parseFloat(slider.step) || 1;
|
||||
let v = parseFloat(slider.value) + step;
|
||||
if (v > parseFloat(slider.max)) v = 0;
|
||||
slider.value = String(v);
|
||||
updateTimelineAt(overlapMin + v, { openModal: isModalOpen() && modalMode === 'timeline' });
|
||||
}, Math.max(200, Math.round(step * 1000)));
|
||||
};
|
||||
const onSlider = () => {
|
||||
if (!singleTrackActive && !dualTracksActive) return;
|
||||
modalMode = 'timeline';
|
||||
updateTimelineAt(sliderTime());
|
||||
};
|
||||
slider.addEventListener('input', onSlider);
|
||||
slider.addEventListener('change', onSlider);
|
||||
|
||||
document.getElementById('btnPlay').onclick = () => {
|
||||
if (!singleTrackActive && !dualTracksActive) return;
|
||||
if (playTimer) {
|
||||
clearInterval(playTimer);
|
||||
playTimer = null;
|
||||
document.getElementById('btnPlay').textContent = '▶ Play';
|
||||
return;
|
||||
}
|
||||
document.getElementById('btnPlay').textContent = '⏸ Pause';
|
||||
const step = parseInt(slider.step, 10) || 100;
|
||||
playTimer = setInterval(() => {
|
||||
let ms = parseInt(slider.value, 10) + step;
|
||||
if (ms > timelineSpanMs) ms = 0;
|
||||
slider.value = String(ms);
|
||||
updateTimelineAt(sliderTime());
|
||||
}, Math.max(100, step));
|
||||
};
|
||||
}
|
||||
|
||||
function buildDeviceStatsHtml(d) {
|
||||
const snap = RadioUI.parseRadioSnapshot(d.meta, d.role, d.rssi);
|
||||
@@ -2370,6 +2441,8 @@
|
||||
const res = await fetch('/api/devices', { cache: 'no-store' });
|
||||
if (!res.ok) throw new Error('devices ' + res.status);
|
||||
const devices = await res.json();
|
||||
rememberDeviceLabels(devices);
|
||||
lastDevices = devices;
|
||||
let tx = 0, rx = 0;
|
||||
devices.forEach(d => { if (d.role === 'TX') tx++; else if (d.role === 'RX') rx++; });
|
||||
document.getElementById('status').textContent =
|
||||
@@ -2419,6 +2492,11 @@
|
||||
updateGpsDistanceHeader(devices);
|
||||
if (mapRulerOpen && mapRulerMode === 'auto') loadMapRulerProfileAuto();
|
||||
updateCmdTargetSelect(devices);
|
||||
if (selectedId && !devices.find(d => d.device_id === selectedId)) {
|
||||
selectedId = null;
|
||||
setPanelHtml(document.getElementById('stats'), '<span class="muted">—</span>');
|
||||
document.getElementById('history').innerHTML = '';
|
||||
}
|
||||
if (selectedId) {
|
||||
const sel = devices.find(d => d.device_id === selectedId);
|
||||
if (sel) {
|
||||
@@ -2491,7 +2569,7 @@
|
||||
const isNew = m.ts > chatLastReadTs;
|
||||
const div = document.createElement('div');
|
||||
div.className = 'chat-msg ' + (self ? 'chat-self' : 'chat-other') + (isNew ? ' chat-new' : '');
|
||||
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_id));
|
||||
const author = self ? 'Вы' : escapeHtml(deviceDisplayName(m.device_label ? m : m.device_id));
|
||||
div.innerHTML = `<div class="chat-meta">${new Date(m.ts*1000).toLocaleTimeString()} · ${author}</div>${escapeHtml(m.text)}`;
|
||||
log.appendChild(div);
|
||||
if (isNew) {
|
||||
@@ -2606,6 +2684,7 @@
|
||||
|
||||
schedulePoll();
|
||||
setupCmdFormDirtyTracking();
|
||||
setupTimelineControls();
|
||||
loadAllTracks();
|
||||
refreshPairedStatus();
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user