diff --git a/public/live.css b/public/live.css
index f2ac89e..2ed8432 100644
--- a/public/live.css
+++ b/public/live.css
@@ -544,3 +544,20 @@
.vcr-btn { padding: 3px 6px; font-size: 0.7rem; }
.vcr-scope-btn { font-size: 0.6rem; padding: 1px 4px; }
}
+
+/* Timeline time tooltip */
+.vcr-time-tooltip {
+ position: absolute;
+ top: -24px;
+ transform: translateX(-50%);
+ background: rgba(0,0,0,0.85);
+ color: #e2e8f0;
+ font-size: 0.65rem;
+ font-weight: 600;
+ padding: 2px 6px;
+ border-radius: 3px;
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 10;
+}
+.vcr-time-tooltip.hidden { display: none; }
diff --git a/public/live.js b/public/live.js
index d17ed32..a8733e1 100644
--- a/public/live.js
+++ b/public/live.js
@@ -232,6 +232,7 @@
const typeName = raw.type || pkt.payload_type_name || 'UNKNOWN';
return {
id: pkt.id, hash: pkt.hash,
+ _ts: new Date(pkt.timestamp || pkt.created_at).getTime(),
decoded: { header: { payloadTypeName: typeName }, payload: raw, path: { hops } },
snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
};
@@ -239,7 +240,8 @@
// Buffer a packet from WS
function bufferPacket(pkt) {
- const entry = { ts: Date.now(), pkt };
+ pkt._ts = Date.now();
+ const entry = { ts: pkt._ts, pkt };
VCR.buffer.push(entry);
// Keep buffer capped at ~2000
if (VCR.buffer.length > 2000) VCR.buffer.splice(0, 500);
@@ -419,6 +421,7 @@
@@ -519,6 +522,20 @@
// Timeline click to scrub
document.getElementById('vcrTimeline').addEventListener('click', handleTimelineClick);
+ // Timeline hover — show time tooltip
+ const timelineEl = document.getElementById('vcrTimeline');
+ const timeTooltip = document.getElementById('vcrTimeTooltip');
+ timelineEl.addEventListener('mousemove', (e) => {
+ const rect = timelineEl.getBoundingClientRect();
+ const pct = (e.clientX - rect.left) / rect.width;
+ const ts = Date.now() - VCR.timelineScope + pct * VCR.timelineScope;
+ const d = new Date(ts);
+ timeTooltip.textContent = d.toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'});
+ timeTooltip.style.left = (e.clientX - rect.left) + 'px';
+ timeTooltip.classList.remove('hidden');
+ });
+ timelineEl.addEventListener('mouseleave', () => { timeTooltip.classList.add('hidden'); });
+
// Refresh timeline periodically
setInterval(updateTimeline, 5000);
@@ -538,6 +555,16 @@
livePage.addEventListener('click', showNav);
}
showNav();
+
+ // Check for replay packet from packets page
+ const replayData = sessionStorage.getItem('replay-packet');
+ if (replayData) {
+ sessionStorage.removeItem('replay-packet');
+ try {
+ const pkt = JSON.parse(replayData);
+ setTimeout(() => animatePacket(pkt), 1500); // let map load first
+ } catch {}
+ }
}
function injectSVGFilters() {
@@ -622,7 +649,8 @@
const pkts = (data.packets || []).reverse();
pkts.forEach((pkt, i) => {
const livePkt = dbPacketToLive(pkt);
- const ts = new Date(pkt.timestamp || pkt.created_at).getTime();
+ livePkt._ts = new Date(pkt.timestamp || pkt.created_at).getTime();
+ const ts = livePkt._ts;
VCR.buffer.push({ ts, pkt: livePkt });
setTimeout(() => animatePacket(livePkt), i * 400);
});
@@ -902,7 +930,7 @@
${typeName}
${hopStr}
${escapeHtml(preview)}
- ${new Date().toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}
+ ${new Date(pkt._ts || Date.now()).toLocaleTimeString([], {hour:'2-digit',minute:'2-digit',second:'2-digit'})}
`;
item.addEventListener('click', () => showFeedCard(item, pkt, color));
feed.prepend(item);
diff --git a/public/packets.js b/public/packets.js
index 3367e35..6f1dd73 100644
--- a/public/packets.js
+++ b/public/packets.js
@@ -395,7 +395,25 @@
${createColoredHexDump(pkt.raw_hex, ranges)}
` : ''}
${hasRawHex ? buildFieldTable(pkt, decoded, pathHops, ranges) : buildDecodedTable(decoded)}
+
+
`;
+
+ // Wire up replay button
+ const replayBtn = panel.querySelector('.replay-live-btn');
+ if (replayBtn) {
+ replayBtn.addEventListener('click', () => {
+ // Store packet in sessionStorage for the live page to pick up
+ const livePkt = {
+ id: pkt.id, hash: pkt.hash,
+ _ts: new Date(pkt.timestamp).getTime(),
+ decoded: { header: { payloadTypeName: typeName }, payload: decoded, path: { hops: pathHops } },
+ snr: pkt.snr, rssi: pkt.rssi, observer: pkt.observer_name
+ };
+ sessionStorage.setItem('replay-packet', JSON.stringify(livePkt));
+ window.location.hash = '#/live';
+ });
+ }
}
function escapeHtml(s) {
diff --git a/public/style.css b/public/style.css
index 9242d39..a5089bc 100644
--- a/public/style.css
+++ b/public/style.css
@@ -1067,3 +1067,20 @@ button.ch-item.ch-item-encrypted .ch-badge { filter: grayscale(0.6); }
.node-qr svg { max-width: 140px; border-radius: 4px; }
[data-theme="dark"] .node-qr svg rect[fill="#ffffff"] { fill: var(--card-bg); }
[data-theme="dark"] .node-qr svg rect[fill="#000000"] { fill: var(--text); }
+
+/* Replay on Live Map button in packet detail */
+.replay-live-btn {
+ display: block;
+ width: 100%;
+ margin-top: 12px;
+ padding: 8px 16px;
+ background: rgba(168, 85, 247, 0.15);
+ border: 1px solid rgba(168, 85, 247, 0.3);
+ color: #c084fc;
+ font-size: 0.85rem;
+ font-weight: 600;
+ border-radius: 6px;
+ cursor: pointer;
+ transition: background 0.15s;
+}
+.replay-live-btn:hover { background: rgba(168, 85, 247, 0.3); }