1331 lines
48 KiB
HTML
1331 lines
48 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ProofShot — Verification Report</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
|
background: #0d1117;
|
|
color: #c9d1d9;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
.header {
|
|
padding: 24px 32px;
|
|
border-bottom: 1px solid #21262d;
|
|
background: #161b22;
|
|
}
|
|
|
|
.header h1 {
|
|
font-size: 20px;
|
|
font-weight: 600;
|
|
color: #f0f6fc;
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.header .description {
|
|
font-size: 14px;
|
|
color: #8b949e;
|
|
margin-bottom: 6px;
|
|
}
|
|
|
|
.header .description.clamped .description-text {
|
|
display: -webkit-box;
|
|
-webkit-line-clamp: 2;
|
|
-webkit-box-orient: vertical;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.header .description .show-more {
|
|
background: none;
|
|
border: none;
|
|
color: #58a6ff;
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
margin-top: 4px;
|
|
display: block;
|
|
}
|
|
|
|
.header .description .show-more:hover {
|
|
text-decoration: underline;
|
|
}
|
|
|
|
.header .meta {
|
|
font-size: 12px;
|
|
color: #484f58;
|
|
}
|
|
|
|
/* Overlay toggle controls */
|
|
.overlay-toggle {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
font-weight: 400;
|
|
text-transform: none;
|
|
letter-spacing: 0;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.overlay-toggle .tooltip {
|
|
display: none;
|
|
position: absolute;
|
|
top: 100%;
|
|
right: 0;
|
|
margin-top: 6px;
|
|
background: #1c2128;
|
|
color: #c9d1d9;
|
|
font-size: 11px;
|
|
padding: 6px 10px;
|
|
border-radius: 6px;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
border: 1px solid #30363d;
|
|
z-index: 10;
|
|
}
|
|
|
|
.overlay-toggle:hover .tooltip {
|
|
display: block;
|
|
}
|
|
|
|
.overlay-toggle input[type="checkbox"] {
|
|
display: none;
|
|
}
|
|
|
|
.toggle-track {
|
|
position: relative;
|
|
width: 34px;
|
|
height: 18px;
|
|
background: #30363d;
|
|
border-radius: 9px;
|
|
transition: background 0.2s;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toggle-track::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 2px;
|
|
left: 2px;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: #8b949e;
|
|
border-radius: 50%;
|
|
transition: transform 0.2s, background 0.2s;
|
|
}
|
|
|
|
.overlay-toggle input:checked + .toggle-track {
|
|
background: #1f6feb;
|
|
}
|
|
|
|
.overlay-toggle input:checked + .toggle-track::after {
|
|
transform: translateX(16px);
|
|
background: #fff;
|
|
}
|
|
|
|
.error-badges {
|
|
display: flex;
|
|
gap: 12px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.error-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 4px 12px;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
font-weight: 500;
|
|
cursor: pointer;
|
|
transition: opacity 0.15s, transform 0.1s;
|
|
font-family: inherit;
|
|
line-height: inherit;
|
|
}
|
|
|
|
.error-badge:hover {
|
|
opacity: 0.85;
|
|
transform: translateY(-1px);
|
|
}
|
|
|
|
.error-badge.clean {
|
|
background: rgba(63, 185, 80, 0.12);
|
|
color: #3fb950;
|
|
border: 1px solid rgba(63, 185, 80, 0.25);
|
|
}
|
|
|
|
.error-badge.has-errors {
|
|
background: rgba(248, 81, 73, 0.12);
|
|
color: #f85149;
|
|
border: 1px solid rgba(248, 81, 73, 0.25);
|
|
}
|
|
|
|
.error-badge .badge-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.error-badge.clean .badge-dot {
|
|
background: #3fb950;
|
|
}
|
|
|
|
.error-badge.has-errors .badge-dot {
|
|
background: #f85149;
|
|
}
|
|
|
|
.viewer {
|
|
display: flex;
|
|
height: calc(100vh - 180px);
|
|
min-height: 400px;
|
|
}
|
|
|
|
.video-panel {
|
|
flex: 0 0 62%;
|
|
padding: 16px;
|
|
display: flex;
|
|
align-items: flex-start;
|
|
justify-content: center;
|
|
background: #0d1117;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.video-wrapper {
|
|
width: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.video-container {
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.video-container video {
|
|
width: 100%;
|
|
border-radius: 8px 8px 0 0;
|
|
background: #000;
|
|
display: block;
|
|
}
|
|
|
|
.video-overlay {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
overflow: hidden;
|
|
border-radius: 8px 8px 0 0;
|
|
}
|
|
|
|
/* Scrub bar */
|
|
.scrub-bar {
|
|
position: relative;
|
|
width: 100%;
|
|
padding: 8px 0 6px;
|
|
background: #161b22;
|
|
border-radius: 0 0 8px 8px;
|
|
border-top: 1px solid #21262d;
|
|
}
|
|
|
|
.scrub-track {
|
|
position: relative;
|
|
height: 6px;
|
|
background: #21262d;
|
|
border-radius: 3px;
|
|
margin: 0 16px;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.scrub-track:hover {
|
|
height: 8px;
|
|
margin-top: -1px;
|
|
}
|
|
|
|
.scrub-progress {
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
height: 100%;
|
|
background: #58a6ff;
|
|
border-radius: 3px;
|
|
pointer-events: none;
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.scrub-playhead {
|
|
position: absolute;
|
|
top: 50%;
|
|
width: 14px;
|
|
height: 14px;
|
|
background: #f0f6fc;
|
|
border: 2px solid #58a6ff;
|
|
border-radius: 50%;
|
|
transform: translate(-50%, -50%);
|
|
pointer-events: none;
|
|
z-index: 3;
|
|
box-shadow: 0 0 4px rgba(0,0,0,0.4);
|
|
transition: left 0.1s linear;
|
|
}
|
|
|
|
.scrub-marker {
|
|
position: absolute;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
z-index: 2;
|
|
cursor: pointer;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.scrub-marker-icon {
|
|
font-size: 14px;
|
|
width: 22px;
|
|
height: 22px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: #21262d;
|
|
border: 1.5px solid #30363d;
|
|
border-radius: 50%;
|
|
transition: all 0.15s;
|
|
}
|
|
|
|
.scrub-marker:hover .scrub-marker-icon,
|
|
.scrub-marker.active .scrub-marker-icon {
|
|
background: #1f2a37;
|
|
border-color: #58a6ff;
|
|
transform: scale(1.25);
|
|
}
|
|
|
|
.scrub-tooltip {
|
|
display: none;
|
|
position: absolute;
|
|
bottom: 100%;
|
|
left: 0;
|
|
margin-bottom: 8px;
|
|
padding: 6px 10px;
|
|
background: #1c2128;
|
|
border: 1px solid #30363d;
|
|
border-radius: 6px;
|
|
font-size: 12px;
|
|
color: #c9d1d9;
|
|
white-space: nowrap;
|
|
pointer-events: none;
|
|
z-index: 20;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
|
|
}
|
|
|
|
.scrub-tooltip .tooltip-icon {
|
|
margin-right: 4px;
|
|
}
|
|
|
|
.scrub-tooltip .tooltip-time {
|
|
color: #58a6ff;
|
|
margin-left: 6px;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.no-video {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 100%;
|
|
height: 300px;
|
|
border: 1px dashed #30363d;
|
|
border-radius: 8px;
|
|
color: #484f58;
|
|
font-size: 15px;
|
|
}
|
|
|
|
.no-video-hint {
|
|
font-size: 12px;
|
|
margin-top: 8px;
|
|
color: #30363d;
|
|
}
|
|
|
|
.timeline-panel {
|
|
flex: 0 0 38%;
|
|
border-left: 1px solid #21262d;
|
|
overflow-y: auto;
|
|
background: #161b22;
|
|
}
|
|
|
|
/* Tab bar */
|
|
.panel-tabs {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 12px;
|
|
border-bottom: 1px solid #21262d;
|
|
position: sticky;
|
|
top: 0;
|
|
background: #161b22;
|
|
z-index: 10;
|
|
gap: 0;
|
|
}
|
|
|
|
.panel-tab {
|
|
background: none;
|
|
border: none;
|
|
border-bottom: 2px solid transparent;
|
|
color: #8b949e;
|
|
font-size: 13px;
|
|
font-weight: 600;
|
|
padding: 10px 16px;
|
|
cursor: pointer;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
transition: color 0.15s, border-color 0.15s;
|
|
white-space: nowrap;
|
|
font-family: inherit;
|
|
}
|
|
|
|
.panel-tab:hover { color: #c9d1d9; }
|
|
.panel-tab.active { color: #f0f6fc; border-bottom-color: #58a6ff; }
|
|
|
|
.panel-tab-actions {
|
|
margin-left: auto;
|
|
display: flex;
|
|
align-items: center;
|
|
}
|
|
|
|
/* Log tab content */
|
|
.log-tab-content {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
}
|
|
|
|
.log-tab-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 8px 16px;
|
|
border-bottom: 1px solid #21262d;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.log-pre {
|
|
margin: 0;
|
|
padding: 12px 16px;
|
|
background: #0d1117;
|
|
font-family: 'SF Mono', SFMono-Regular, 'Consolas', 'Liberation Mono', Menlo, monospace;
|
|
font-size: 12px;
|
|
line-height: 1.6;
|
|
color: #c9d1d9;
|
|
overflow-x: auto;
|
|
white-space: pre;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
}
|
|
|
|
.log-pre::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
.log-pre::-webkit-scrollbar-track { background: transparent; }
|
|
.log-pre::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
|
.log-pre::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
|
|
|
.log-line { display: block; }
|
|
.log-line[data-time] { cursor: pointer; transition: background 0.15s; padding: 0 4px; margin: 0 -4px; border-radius: 2px; }
|
|
.log-line[data-time]:hover { background: rgba(88, 166, 255, 0.08); }
|
|
.log-line.active { background: #1f2a37; border-left: 3px solid #58a6ff; padding-left: 1px; }
|
|
.log-line.active .log-time { color: #58a6ff; }
|
|
|
|
.log-time {
|
|
display: inline-block;
|
|
min-width: 36px;
|
|
padding-right: 8px;
|
|
text-align: right;
|
|
color: #484f58;
|
|
user-select: none;
|
|
font-variant-numeric: tabular-nums;
|
|
font-size: 11px;
|
|
}
|
|
|
|
.log-ln {
|
|
display: inline-block;
|
|
min-width: 40px;
|
|
padding-right: 12px;
|
|
text-align: right;
|
|
color: #484f58;
|
|
user-select: none;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.log-line-error { background: rgba(248, 81, 73, 0.1); color: #f85149; }
|
|
.log-line-error .log-ln { color: rgba(248, 81, 73, 0.5); }
|
|
.log-line-error .log-time { color: rgba(248, 81, 73, 0.5); }
|
|
|
|
.log-empty {
|
|
padding: 32px 16px;
|
|
text-align: center;
|
|
color: #484f58;
|
|
font-size: 13px;
|
|
font-style: italic;
|
|
}
|
|
|
|
.log-truncated {
|
|
padding: 8px 16px;
|
|
font-size: 11px;
|
|
color: #484f58;
|
|
font-style: italic;
|
|
border-top: 1px solid #21262d;
|
|
background: #161b22;
|
|
}
|
|
|
|
.step {
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 10px 20px;
|
|
cursor: pointer;
|
|
border-bottom: 1px solid #21262d;
|
|
transition: background 0.15s;
|
|
gap: 10px;
|
|
}
|
|
|
|
.step:hover {
|
|
background: #1c2128;
|
|
}
|
|
|
|
.step.active {
|
|
background: #1f2a37;
|
|
border-left: 3px solid #58a6ff;
|
|
padding-left: 17px;
|
|
}
|
|
|
|
.step-number {
|
|
font-size: 11px;
|
|
color: #484f58;
|
|
min-width: 20px;
|
|
text-align: right;
|
|
font-variant-numeric: tabular-nums;
|
|
}
|
|
|
|
.icon {
|
|
font-size: 16px;
|
|
min-width: 24px;
|
|
text-align: center;
|
|
}
|
|
|
|
.step-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.action {
|
|
font-size: 13px;
|
|
font-family: 'SF Mono', SFMono-Regular, 'Consolas', 'Liberation Mono', Menlo, monospace;
|
|
color: #c9d1d9;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.step.active .action {
|
|
color: #f0f6fc;
|
|
}
|
|
|
|
.time {
|
|
font-size: 12px;
|
|
color: #484f58;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 36px;
|
|
text-align: right;
|
|
}
|
|
|
|
.step.active .time {
|
|
color: #58a6ff;
|
|
}
|
|
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 200px;
|
|
color: #484f58;
|
|
font-size: 14px;
|
|
}
|
|
|
|
/* Overlay animations */
|
|
.ripple {
|
|
position: absolute;
|
|
border-radius: 50%;
|
|
pointer-events: none;
|
|
transform: translate(-50%, -50%);
|
|
animation: ripple-expand 600ms ease-out forwards;
|
|
}
|
|
|
|
@keyframes ripple-expand {
|
|
0% { width: 12px; height: 12px; opacity: 0.7; }
|
|
100% { width: 60px; height: 60px; opacity: 0; }
|
|
}
|
|
|
|
.ripple-click { background: rgba(56, 132, 255, 0.5); }
|
|
.ripple-fill { background: rgba(255, 152, 56, 0.5); }
|
|
|
|
.scroll-indicator {
|
|
position: absolute;
|
|
left: 50%;
|
|
top: 50%;
|
|
transform: translate(-50%, -50%);
|
|
font-size: 32px;
|
|
opacity: 0.6;
|
|
pointer-events: none;
|
|
animation: fade-out 800ms ease-out forwards;
|
|
}
|
|
|
|
@keyframes fade-out {
|
|
0% { opacity: 0.6; }
|
|
100% { opacity: 0; }
|
|
}
|
|
|
|
.toast {
|
|
position: absolute;
|
|
bottom: 32px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(0, 0, 0, 0.85);
|
|
color: #fff;
|
|
padding: 10px 20px;
|
|
border-radius: 10px;
|
|
font-size: 15px;
|
|
font-weight: 500;
|
|
pointer-events: none;
|
|
animation: toast-in 200ms ease-out;
|
|
white-space: nowrap;
|
|
letter-spacing: 0.2px;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
|
}
|
|
|
|
@keyframes toast-in {
|
|
0% { opacity: 0; transform: translateX(-50%) translateY(8px); }
|
|
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
|
|
}
|
|
|
|
/* Scrollbar styling */
|
|
.timeline-panel::-webkit-scrollbar { width: 6px; }
|
|
.timeline-panel::-webkit-scrollbar-track { background: transparent; }
|
|
.timeline-panel::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
|
.timeline-panel::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
|
|
|
@media (max-width: 768px) {
|
|
.viewer {
|
|
flex-direction: column;
|
|
height: auto;
|
|
}
|
|
.video-panel, .timeline-panel {
|
|
flex: none;
|
|
width: 100%;
|
|
}
|
|
.timeline-panel {
|
|
border-left: none;
|
|
border-top: 1px solid #21262d;
|
|
max-height: 50vh;
|
|
}
|
|
.error-badges {
|
|
flex-wrap: wrap;
|
|
}
|
|
.log-pre {
|
|
max-height: 50vh;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="header">
|
|
<h1><svg viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg" style="width:24px;height:24px;vertical-align:middle;margin-right:8px"><path d="M8,24 L8,12 C8,8 12,8 12,8 L24,8" fill="none" stroke="#6366F1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M40,8 L52,8 C56,8 56,12 56,12 L56,24" fill="none" stroke="#6366F1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M8,40 L8,52 C8,56 12,56 12,56 L24,56" fill="none" stroke="#6366F1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M40,56 L52,56 C56,56 56,52 56,52 L56,40" fill="none" stroke="#6366F1" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/><path d="M20,34 L28,42 L44,22" fill="none" stroke="#22D3EE" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/></svg>ProofShot Verification</h1>
|
|
<p class="description" id="description"><span class="description-text">Verify B: CSS strikethrough for struck-through rule, no fake input for illegible</span><button class="show-more" id="showMoreBtn" style="display:none" onclick="toggleDescription()">Show more</button></p>
|
|
<p class="meta">2026-04-25 11:04:14 · 88s</p>
|
|
<div class="error-badges">
|
|
<button class="error-badge clean" onclick="switchTab('console')"><span class="badge-dot"></span>Console: clean</button>
|
|
<button class="error-badge clean" onclick="switchTab('server')"><span class="badge-dot"></span>Server: clean</button>
|
|
</div>
|
|
</div>
|
|
<div class="viewer">
|
|
<div class="video-panel">
|
|
<div class="video-wrapper">
|
|
<div class="video-container">
|
|
<video src="./session.webm" controls></video>
|
|
<div class="video-overlay"></div>
|
|
</div>
|
|
<div class="scrub-bar">
|
|
<div class="scrub-track" id="scrubTrack">
|
|
<div class="scrub-progress" id="scrubProgress"></div>
|
|
<div class="scrub-playhead" id="scrubPlayhead"></div>
|
|
<div class="scrub-marker" data-index="0" data-time="2.8" style="left:3.1818181818181817%"><span class="scrub-marker-icon">🧭</span></div>
|
|
<div class="scrub-marker" data-index="1" data-time="3.3" style="left:3.75%"><span class="scrub-marker-icon">📷</span></div>
|
|
<div class="scrub-marker" data-index="2" data-time="6.5" style="left:7.386363636363637%"><span class="scrub-marker-icon">👁</span></div>
|
|
<div class="scrub-marker" data-index="3" data-time="10.7" style="left:12.159090909090908%"><span class="scrub-marker-icon">⌨</span></div>
|
|
<div class="scrub-marker" data-index="4" data-time="11.5" style="left:13.068181818181818%"><span class="scrub-marker-icon">⌨</span></div>
|
|
<div class="scrub-marker" data-index="5" data-time="12.7" style="left:14.431818181818182%"><span class="scrub-marker-icon">🖱</span></div>
|
|
<div class="scrub-marker" data-index="6" data-time="14.9" style="left:16.93181818181818%"><span class="scrub-marker-icon">🧭</span></div>
|
|
<div class="scrub-marker" data-index="7" data-time="15.9" style="left:18.06818181818182%"><span class="scrub-marker-icon">📷</span></div>
|
|
<div class="scrub-marker" data-index="8" data-time="27.8" style="left:31.590909090909093%"><span class="scrub-marker-icon">👁</span></div>
|
|
<div class="scrub-marker" data-index="9" data-time="38.6" style="left:43.86363636363637%"><span class="scrub-marker-icon">🖱</span></div>
|
|
<div class="scrub-marker" data-index="10" data-time="39" style="left:44.31818181818182%"><span class="scrub-marker-icon">📷</span></div>
|
|
<div class="scrub-marker" data-index="11" data-time="62.2" style="left:70.68181818181819%"><span class="scrub-marker-icon">🧭</span></div>
|
|
<div class="scrub-marker" data-index="12" data-time="63.7" style="left:72.38636363636364%"><span class="scrub-marker-icon">📷</span></div>
|
|
<div class="scrub-marker" data-index="13" data-time="77.7" style="left:88.29545454545455%"><span class="scrub-marker-icon">↕</span></div>
|
|
<div class="scrub-marker" data-index="14" data-time="80.7" style="left:91.70454545454547%"><span class="scrub-marker-icon">↕</span></div>
|
|
<div class="scrub-marker" data-index="15" data-time="81" style="left:92.04545454545455%"><span class="scrub-marker-icon">📷</span></div>
|
|
</div>
|
|
<div class="scrub-tooltip" id="scrubTooltip"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="timeline-panel">
|
|
<div class="panel-tabs">
|
|
<button class="panel-tab active" data-tab="timeline" onclick="switchTab('timeline')">Timeline · 16</button>
|
|
<button class="panel-tab" data-tab="console" onclick="switchTab('console')">Console · 13</button>
|
|
<button class="panel-tab" data-tab="server" onclick="switchTab('server')">Server</button>
|
|
<div class="panel-tab-actions" id="tabActionsTimeline">
|
|
<label class="overlay-toggle"><input type="checkbox" id="toggle-overlays" checked><span class="toggle-track"></span> Overlays<span class="tooltip">Show ripple animations and action labels on the video as each step plays.</span></label>
|
|
</div>
|
|
</div>
|
|
<div id="tabTimeline">
|
|
<div class="step" data-time="2.8" data-index="0" onclick="seekTo(2.8)">
|
|
<span class="step-number">1</span>
|
|
<span class="icon">🧭</span>
|
|
<div class="step-content">
|
|
<span class="action">open http://localhost:5173/hilfe/transkription</span>
|
|
</div>
|
|
<span class="time">0:02</span>
|
|
</div>
|
|
<div class="step" data-time="3.3" data-index="1" onclick="seekTo(3.3)">
|
|
<span class="step-number">2</span>
|
|
<span class="icon">📷</span>
|
|
<div class="step-content">
|
|
<span class="action">screenshot step-rules.png</span>
|
|
</div>
|
|
<span class="time">0:03</span>
|
|
</div>
|
|
<div class="step" data-time="6.5" data-index="2" onclick="seekTo(6.5)">
|
|
<span class="step-number">3</span>
|
|
<span class="icon">👁</span>
|
|
<div class="step-content">
|
|
<span class="action">snapshot -i</span>
|
|
</div>
|
|
<span class="time">0:06</span>
|
|
</div>
|
|
<div class="step" data-time="10.7" data-index="3" onclick="seekTo(10.7)">
|
|
<span class="step-number">4</span>
|
|
<span class="icon">⌨</span>
|
|
<div class="step-content">
|
|
<span class="action">fill @e6 admin@familyarchive.local</span>
|
|
</div>
|
|
<span class="time">0:10</span>
|
|
</div>
|
|
<div class="step" data-time="11.5" data-index="4" onclick="seekTo(11.5)">
|
|
<span class="step-number">5</span>
|
|
<span class="icon">⌨</span>
|
|
<div class="step-content">
|
|
<span class="action">fill @e7 admin123</span>
|
|
</div>
|
|
<span class="time">0:11</span>
|
|
</div>
|
|
<div class="step" data-time="12.7" data-index="5" onclick="seekTo(12.7)">
|
|
<span class="step-number">6</span>
|
|
<span class="icon">🖱</span>
|
|
<div class="step-content">
|
|
<span class="action">click @e8</span>
|
|
</div>
|
|
<span class="time">0:12</span>
|
|
</div>
|
|
<div class="step" data-time="14.9" data-index="6" onclick="seekTo(14.9)">
|
|
<span class="step-number">7</span>
|
|
<span class="icon">🧭</span>
|
|
<div class="step-content">
|
|
<span class="action">open http://localhost:5173/hilfe/transkription</span>
|
|
</div>
|
|
<span class="time">0:14</span>
|
|
</div>
|
|
<div class="step" data-time="15.9" data-index="7" onclick="seekTo(15.9)">
|
|
<span class="step-number">8</span>
|
|
<span class="icon">📷</span>
|
|
<div class="step-content">
|
|
<span class="action">screenshot step-rules.png</span>
|
|
</div>
|
|
<span class="time">0:15</span>
|
|
</div>
|
|
<div class="step" data-time="27.8" data-index="8" onclick="seekTo(27.8)">
|
|
<span class="step-number">9</span>
|
|
<span class="icon">👁</span>
|
|
<div class="step-content">
|
|
<span class="action">snapshot</span>
|
|
</div>
|
|
<span class="time">0:27</span>
|
|
</div>
|
|
<div class="step" data-time="38.6" data-index="9" onclick="seekTo(38.6)">
|
|
<span class="step-number">10</span>
|
|
<span class="icon">🖱</span>
|
|
<div class="step-content">
|
|
<span class="action">click @e17</span>
|
|
</div>
|
|
<span class="time">0:38</span>
|
|
</div>
|
|
<div class="step" data-time="39" data-index="10" onclick="seekTo(39)">
|
|
<span class="step-number">11</span>
|
|
<span class="icon">📷</span>
|
|
<div class="step-content">
|
|
<span class="action">screenshot step-strikethrough.png</span>
|
|
</div>
|
|
<span class="time">0:39</span>
|
|
</div>
|
|
<div class="step" data-time="62.2" data-index="11" onclick="seekTo(62.2)">
|
|
<span class="step-number">12</span>
|
|
<span class="icon">🧭</span>
|
|
<div class="step-content">
|
|
<span class="action">open http://localhost:5173/hilfe/transkription</span>
|
|
</div>
|
|
<span class="time">1:02</span>
|
|
</div>
|
|
<div class="step" data-time="63.7" data-index="12" onclick="seekTo(63.7)">
|
|
<span class="step-number">13</span>
|
|
<span class="icon">📷</span>
|
|
<div class="step-content">
|
|
<span class="action">screenshot --full-page step-full.png</span>
|
|
</div>
|
|
<span class="time">1:03</span>
|
|
</div>
|
|
<div class="step" data-time="77.7" data-index="13" onclick="seekTo(77.7)">
|
|
<span class="step-number">14</span>
|
|
<span class="icon">↕</span>
|
|
<div class="step-content">
|
|
<span class="action">scroll 0 500</span>
|
|
</div>
|
|
<span class="time">1:17</span>
|
|
</div>
|
|
<div class="step" data-time="80.7" data-index="14" onclick="seekTo(80.7)">
|
|
<span class="step-number">15</span>
|
|
<span class="icon">↕</span>
|
|
<div class="step-content">
|
|
<span class="action">scroll down</span>
|
|
</div>
|
|
<span class="time">1:20</span>
|
|
</div>
|
|
<div class="step" data-time="81" data-index="15" onclick="seekTo(81)">
|
|
<span class="step-number">16</span>
|
|
<span class="icon">📷</span>
|
|
<div class="step-content">
|
|
<span class="action">screenshot step-scrolled.png</span>
|
|
</div>
|
|
<span class="time">1:21</span>
|
|
</div>
|
|
</div>
|
|
<div id="tabConsole" style="display:none">
|
|
<div class="log-tab-content">
|
|
<div class="log-tab-status">
|
|
<span class="error-badge clean" style="cursor:default"><span class="badge-dot"></span>Console: clean</span>
|
|
</div>
|
|
<pre class="log-pre"><span class="log-line" data-time="0" onclick="seekTo(0)"><span class="log-time">0:00</span><span class="log-ln">1</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="0" onclick="seekTo(0)"><span class="log-time">0:00</span><span class="log-ln">2</span>[debug] [vite] connected.</span>
|
|
<span class="log-line" data-time="0" onclick="seekTo(0)"><span class="log-time">0:00</span><span class="log-ln">3</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="0.1" onclick="seekTo(0.1)"><span class="log-time">0:00</span><span class="log-ln">4</span>[debug] [vite] connected.</span>
|
|
<span class="log-line" data-time="3.1" onclick="seekTo(3.1)"><span class="log-time">0:03</span><span class="log-ln">5</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="3.1" onclick="seekTo(3.1)"><span class="log-time">0:03</span><span class="log-ln">6</span>[debug] [vite] connected.</span>
|
|
<span class="log-line" data-time="15" onclick="seekTo(15)"><span class="log-time">0:15</span><span class="log-ln">7</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="15" onclick="seekTo(15)"><span class="log-time">0:15</span><span class="log-ln">8</span>[debug] [vite] connected.</span>
|
|
<span class="log-line" data-time="15.6" onclick="seekTo(15.6)"><span class="log-time">0:15</span><span class="log-ln">9</span>[error] Failed to fetch unread count TypeError: Failed to fetch
|
|
at window.fetch (http://localhost:5173/node_modules/@sveltejs/kit/src/runtime/client/fetcher.js?v=9aff597a:66:10)
|
|
at fetchUnreadCount (http://localhost:5173/src/lib/stores/notifications.svelte.ts?t=1777114433090:31:46)
|
|
at Object.init (http://localhost:5173/src/lib/stores/notifications.svelte.ts?t=1777114433090:74:2)
|
|
at http://localhost:5173/src/lib/components/NotificationBell.svelte?t=1777114433090:71:10
|
|
at untrack (http://localhost:5173/node_modules/.vite/deps/chunk-W6TT6Y22.js?v=9aff597a:4131:12)
|
|
at $effect (http://localhost:5173/node_modules/.vite/deps/chunk-XZDNTRGL.js?v=9aff597a:4165:23)
|
|
at update_reaction (http://localhost:5173/node_modules/.vite/deps/chunk-W6TT6Y22.js?v=9aff597a:3831:18)
|
|
at update_effect (http://localhost:5173/node_modules/.vite/deps/chunk-W6TT6Y22.js?v=9aff597a:3970:21)
|
|
at flush_queued_effects (http://localhost:5173/node_modules/.vite/deps/chunk-W6TT6Y22.js?v=9aff597a:2955:7)
|
|
at #process (http://localhost:5173/node_modules/.vite/deps/chunk-W6TT6Y22.js?v=9aff597a:2613:7)</span>
|
|
<span class="log-line" data-time="15.8" onclick="seekTo(15.8)"><span class="log-time">0:15</span><span class="log-ln">10</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="15.8" onclick="seekTo(15.8)"><span class="log-time">0:15</span><span class="log-ln">11</span>[debug] [vite] connected.</span>
|
|
<span class="log-line" data-time="62.6" onclick="seekTo(62.6)"><span class="log-time">1:02</span><span class="log-ln">12</span>[debug] [vite] connecting...</span>
|
|
<span class="log-line" data-time="62.6" onclick="seekTo(62.6)"><span class="log-time">1:02</span><span class="log-ln">13</span>[debug] [vite] connected.</span></pre>
|
|
</div>
|
|
</div>
|
|
<div id="tabServer" style="display:none">
|
|
<div class="log-tab-content">
|
|
<div class="log-tab-status">
|
|
<span class="error-badge clean" style="cursor:default"><span class="badge-dot"></span>Server: clean</span>
|
|
</div>
|
|
<p class="log-empty">No server log captured</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
// --- Description expand/collapse ---
|
|
function initDescription() {
|
|
const desc = document.getElementById('description');
|
|
const btn = document.getElementById('showMoreBtn');
|
|
if (!desc || !btn) return;
|
|
const textEl = desc.querySelector('.description-text');
|
|
// Clamp initially, then check if text overflows
|
|
desc.classList.add('clamped');
|
|
requestAnimationFrame(() => {
|
|
if (textEl.scrollHeight > textEl.clientHeight + 1) {
|
|
btn.style.display = 'block';
|
|
}
|
|
});
|
|
}
|
|
function toggleDescription() {
|
|
const desc = document.getElementById('description');
|
|
const btn = document.getElementById('showMoreBtn');
|
|
if (!desc || !btn) return;
|
|
const isClamped = desc.classList.contains('clamped');
|
|
desc.classList.toggle('clamped');
|
|
btn.textContent = isClamped ? 'Show less' : 'Show more';
|
|
}
|
|
initDescription();
|
|
|
|
// --- Tab switching ---
|
|
let activeTab = 'timeline';
|
|
|
|
function switchTab(tab) {
|
|
if (tab === activeTab) return;
|
|
activeTab = tab;
|
|
document.querySelectorAll('.panel-tab').forEach(function(btn) {
|
|
btn.classList.toggle('active', btn.dataset.tab === tab);
|
|
});
|
|
document.getElementById('tabTimeline').style.display = tab === 'timeline' ? '' : 'none';
|
|
document.getElementById('tabConsole').style.display = tab === 'console' ? '' : 'none';
|
|
document.getElementById('tabServer').style.display = tab === 'server' ? '' : 'none';
|
|
var actions = document.getElementById('tabActionsTimeline');
|
|
if (actions) actions.style.display = tab === 'timeline' ? '' : 'none';
|
|
}
|
|
|
|
const video = document.querySelector('video');
|
|
const steps = document.querySelectorAll('.step');
|
|
const timelinePanel = document.querySelector('.timeline-panel');
|
|
const overlay = document.querySelector('.video-overlay');
|
|
const entries = [{"action":"open http://localhost:5173/hilfe/transkription","relativeTimeSec":2.8,"timestamp":"2026-04-25T11:02:46.720Z"},{"action":"screenshot step-rules.png","relativeTimeSec":3.3,"timestamp":"2026-04-25T11:02:47.146Z"},{"action":"snapshot -i","relativeTimeSec":6.5,"timestamp":"2026-04-25T11:02:50.423Z"},{"action":"fill @e6 admin@familyarchive.local","relativeTimeSec":10.7,"timestamp":"2026-04-25T11:02:54.622Z","element":{"label":"E-Mail-Adresse","bbox":{"x":481,"y":314,"width":318,"height":42},"viewport":{"width":1280,"height":720}}},{"action":"fill @e7 admin123","relativeTimeSec":11.5,"timestamp":"2026-04-25T11:02:55.421Z","element":{"label":"Passwort","bbox":{"x":481,"y":398,"width":318,"height":42},"viewport":{"width":1280,"height":720}}},{"action":"click @e8","relativeTimeSec":12.7,"timestamp":"2026-04-25T11:02:56.543Z"},{"action":"open http://localhost:5173/hilfe/transkription","relativeTimeSec":14.9,"timestamp":"2026-04-25T11:02:58.745Z"},{"action":"screenshot step-rules.png","relativeTimeSec":15.9,"timestamp":"2026-04-25T11:02:59.804Z"},{"action":"snapshot","relativeTimeSec":27.8,"timestamp":"2026-04-25T11:03:11.712Z"},{"action":"click @e17","relativeTimeSec":38.6,"timestamp":"2026-04-25T11:03:22.492Z","element":{"label":"Durchgestrichene Wörter","bbox":{"x":366,"y":711.5,"width":174,"height":24},"viewport":{"width":1280,"height":720}}},{"action":"screenshot step-strikethrough.png","relativeTimeSec":39,"timestamp":"2026-04-25T11:03:22.830Z"},{"action":"open http://localhost:5173/hilfe/transkription","relativeTimeSec":62.2,"timestamp":"2026-04-25T11:03:46.063Z"},{"action":"screenshot --full-page step-full.png","relativeTimeSec":63.7,"timestamp":"2026-04-25T11:03:47.577Z"},{"action":"scroll 0 500","relativeTimeSec":77.7,"timestamp":"2026-04-25T11:04:01.584Z"},{"action":"scroll down","relativeTimeSec":80.7,"timestamp":"2026-04-25T11:04:04.579Z"},{"action":"screenshot step-scrolled.png","relativeTimeSec":81,"timestamp":"2026-04-25T11:04:04.856Z"}];
|
|
let duration = 88;
|
|
const markers = [{"time":2.8,"icon":"🧭","action":"open http://localhost:5173/hilfe/transkription","index":0},{"time":3.3,"icon":"📷","action":"screenshot step-rules.png","index":1},{"time":6.5,"icon":"👁","action":"snapshot -i","index":2},{"time":10.7,"icon":"⌨","action":"fill @e6 admin@familyarchive.local","index":3},{"time":11.5,"icon":"⌨","action":"fill @e7 admin123","index":4},{"time":12.7,"icon":"🖱","action":"click @e8","index":5},{"time":14.9,"icon":"🧭","action":"open http://localhost:5173/hilfe/transkription","index":6},{"time":15.9,"icon":"📷","action":"screenshot step-rules.png","index":7},{"time":27.8,"icon":"👁","action":"snapshot","index":8},{"time":38.6,"icon":"🖱","action":"click @e17","index":9},{"time":39,"icon":"📷","action":"screenshot step-strikethrough.png","index":10},{"time":62.2,"icon":"🧭","action":"open http://localhost:5173/hilfe/transkription","index":11},{"time":63.7,"icon":"📷","action":"screenshot --full-page step-full.png","index":12},{"time":77.7,"icon":"↕","action":"scroll 0 500","index":13},{"time":80.7,"icon":"↕","action":"scroll down","index":14},{"time":81,"icon":"📷","action":"screenshot step-scrolled.png","index":15}];
|
|
|
|
// Scrub bar elements
|
|
const scrubTrack = document.getElementById('scrubTrack');
|
|
const scrubProgress = document.getElementById('scrubProgress');
|
|
const scrubPlayhead = document.getElementById('scrubPlayhead');
|
|
const scrubTooltip = document.getElementById('scrubTooltip');
|
|
const scrubMarkers = document.querySelectorAll('.scrub-marker');
|
|
|
|
// --- Toggle state ---
|
|
const toggleOverlays = document.getElementById('toggle-overlays');
|
|
|
|
function loadToggleState() {
|
|
try {
|
|
const saved = JSON.parse(localStorage.getItem('proofshot-overlays') || '{}');
|
|
if (saved.overlays === false) toggleOverlays.checked = false;
|
|
} catch {}
|
|
}
|
|
function saveToggleState() {
|
|
try {
|
|
localStorage.setItem('proofshot-overlays', JSON.stringify({
|
|
overlays: toggleOverlays.checked,
|
|
}));
|
|
} catch {}
|
|
}
|
|
loadToggleState();
|
|
|
|
toggleOverlays.addEventListener('change', () => {
|
|
if (!toggleOverlays.checked) clearOverlays();
|
|
saveToggleState();
|
|
});
|
|
|
|
function clearOverlays() {
|
|
if (!overlay) return;
|
|
overlay.querySelectorAll('.ripple, .scroll-indicator, .toast').forEach(el => el.remove());
|
|
}
|
|
|
|
// --- Action icon (mirrors server-side getActionIcon) ---
|
|
function getActionIconJS(action) {
|
|
const cmd = action.split(' ')[0].toLowerCase();
|
|
switch (cmd) {
|
|
case 'open': case 'navigate': return '\u{1F9ED}';
|
|
case 'click': return '\u{1F5B1}';
|
|
case 'fill': case 'type': return '\u2328';
|
|
case 'screenshot': return '\u{1F4F7}';
|
|
case 'snapshot': return '\u{1F441}';
|
|
case 'scroll': return '\u2195';
|
|
case 'press': case 'keyboard': return '\u2318';
|
|
default: return '\u25B6';
|
|
}
|
|
}
|
|
|
|
// --- Toast text generation ---
|
|
function getToastText(entry) {
|
|
const action = entry.action;
|
|
const parts = action.split(' ');
|
|
const cmd = parts[0].toLowerCase();
|
|
const label = entry.element ? entry.element.label : '';
|
|
const icon = getActionIconJS(action);
|
|
|
|
switch (cmd) {
|
|
case 'click':
|
|
return icon + ' Click' + (label ? ': ' + label : '');
|
|
case 'fill': {
|
|
const valMatch = action.match(/"([^"]*)"/);
|
|
const val = valMatch ? valMatch[1] : '';
|
|
const target = label || '';
|
|
return icon + ' Type: ' + val + (target ? ' into ' + target : '');
|
|
}
|
|
case 'type': {
|
|
const valMatch2 = action.match(/"([^"]*)"/);
|
|
const val2 = valMatch2 ? valMatch2[1] : '';
|
|
const target2 = label || '';
|
|
return icon + ' Type: ' + val2 + (target2 ? ' into ' + target2 : '');
|
|
}
|
|
case 'scroll': {
|
|
const dir = parts[1] || '';
|
|
return icon + ' Scroll ' + dir;
|
|
}
|
|
case 'open': {
|
|
const url = parts.slice(1).join(' ');
|
|
try {
|
|
return icon + ' Navigate: ' + new URL(url).pathname;
|
|
} catch {
|
|
return icon + ' Navigate: ' + url;
|
|
}
|
|
}
|
|
case 'press':
|
|
return icon + ' Press: ' + parts.slice(1).join(' ');
|
|
case 'screenshot':
|
|
return icon + ' Screenshot';
|
|
default:
|
|
return icon + ' ' + action;
|
|
}
|
|
}
|
|
|
|
// --- Scroll direction arrows ---
|
|
function getScrollArrow(action) {
|
|
const parts = action.split(' ');
|
|
const dir = (parts[1] || '').toLowerCase();
|
|
switch (dir) {
|
|
case 'up': return '\u2191';
|
|
case 'down': return '\u2193';
|
|
case 'left': return '\u2190';
|
|
case 'right': return '\u2192';
|
|
default: return '\u2195';
|
|
}
|
|
}
|
|
|
|
// --- Overlay scheduling ---
|
|
const overlayWindows = entries.map((entry, i) => {
|
|
const cmd = entry.action.split(' ')[0].toLowerCase();
|
|
const nextTime = i + 1 < entries.length ? entries[i + 1].relativeTimeSec : entry.relativeTimeSec + 3;
|
|
const rippleEnd = entry.relativeTimeSec + 0.6;
|
|
const toastEnd = Math.min(nextTime, entry.relativeTimeSec + 3);
|
|
const scrollEnd = entry.relativeTimeSec + 0.8;
|
|
|
|
return {
|
|
entry,
|
|
cmd,
|
|
rippleStart: entry.relativeTimeSec,
|
|
rippleEnd: cmd === 'scroll' ? scrollEnd : rippleEnd,
|
|
toastStart: entry.relativeTimeSec,
|
|
toastEnd,
|
|
};
|
|
});
|
|
|
|
const activeRipples = new Map();
|
|
const activeToasts = new Map();
|
|
let rafId = null;
|
|
|
|
function renderOverlays() {
|
|
if (!video || !overlay) return;
|
|
const t = video.currentTime;
|
|
const videoEl = video;
|
|
|
|
overlayWindows.forEach((win, idx) => {
|
|
const enabled = toggleOverlays.checked;
|
|
|
|
// --- Ripple / scroll indicator ---
|
|
if (enabled) {
|
|
if (t >= win.rippleStart && t < win.rippleEnd && !activeRipples.has(idx)) {
|
|
const el = document.createElement('div');
|
|
|
|
if (win.cmd === 'scroll') {
|
|
el.className = 'scroll-indicator';
|
|
el.textContent = getScrollArrow(win.entry.action);
|
|
overlay.appendChild(el);
|
|
activeRipples.set(idx, el);
|
|
} else if ((win.cmd === 'click' || win.cmd === 'fill' || win.cmd === 'type') && win.entry.element) {
|
|
const elem = win.entry.element;
|
|
const scaleX = videoEl.clientWidth / elem.viewport.width;
|
|
const scaleY = videoEl.clientHeight / elem.viewport.height;
|
|
const cx = (elem.bbox.x + elem.bbox.width / 2) * scaleX;
|
|
const cy = (elem.bbox.y + elem.bbox.height / 2) * scaleY;
|
|
|
|
el.className = 'ripple ' + (win.cmd === 'click' ? 'ripple-click' : 'ripple-fill');
|
|
el.style.left = cx + 'px';
|
|
el.style.top = cy + 'px';
|
|
overlay.appendChild(el);
|
|
activeRipples.set(idx, el);
|
|
}
|
|
}
|
|
if (t >= win.rippleEnd && activeRipples.has(idx)) {
|
|
activeRipples.get(idx).remove();
|
|
activeRipples.delete(idx);
|
|
}
|
|
} else if (activeRipples.has(idx)) {
|
|
activeRipples.get(idx).remove();
|
|
activeRipples.delete(idx);
|
|
}
|
|
|
|
// --- Toast ---
|
|
if (enabled) {
|
|
if (t >= win.toastStart && t < win.toastEnd && !activeToasts.has(idx)) {
|
|
activeToasts.forEach((el) => el.remove());
|
|
activeToasts.clear();
|
|
|
|
const el = document.createElement('div');
|
|
el.className = 'toast';
|
|
el.textContent = getToastText(win.entry);
|
|
overlay.appendChild(el);
|
|
activeToasts.set(idx, el);
|
|
}
|
|
if (t >= win.toastEnd && activeToasts.has(idx)) {
|
|
activeToasts.get(idx).remove();
|
|
activeToasts.delete(idx);
|
|
}
|
|
} else if (activeToasts.has(idx)) {
|
|
activeToasts.get(idx).remove();
|
|
activeToasts.delete(idx);
|
|
}
|
|
});
|
|
|
|
rafId = requestAnimationFrame(renderOverlays);
|
|
}
|
|
|
|
function startOverlayLoop() {
|
|
if (rafId !== null) return;
|
|
rafId = requestAnimationFrame(renderOverlays);
|
|
}
|
|
|
|
function stopOverlayLoop() {
|
|
if (rafId !== null) {
|
|
cancelAnimationFrame(rafId);
|
|
rafId = null;
|
|
}
|
|
}
|
|
|
|
// --- Seek handler: clear overlays on seek so they re-trigger correctly ---
|
|
function onSeeked() {
|
|
activeRipples.forEach(el => el.remove());
|
|
activeRipples.clear();
|
|
activeToasts.forEach(el => el.remove());
|
|
activeToasts.clear();
|
|
}
|
|
|
|
function seekTo(time) {
|
|
if (video) {
|
|
video.currentTime = time;
|
|
video.play();
|
|
}
|
|
}
|
|
|
|
function formatTimeFn(sec) {
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.floor(sec % 60);
|
|
return m + ':' + String(s).padStart(2, '0');
|
|
}
|
|
|
|
// Update scrub bar position
|
|
function updateScrubBar(t) {
|
|
if (!scrubTrack || duration <= 0) return;
|
|
const pct = Math.min((t / duration) * 100, 100);
|
|
if (scrubProgress) scrubProgress.style.width = pct + '%';
|
|
if (scrubPlayhead) scrubPlayhead.style.left = pct + '%';
|
|
}
|
|
|
|
// Highlight active marker on scrub bar
|
|
function updateActiveMarker(t) {
|
|
scrubMarkers.forEach(m => {
|
|
const mTime = parseFloat(m.dataset.time);
|
|
const idx = parseInt(m.dataset.index);
|
|
const nextMarker = markers[idx + 1];
|
|
const nextTime = nextMarker ? nextMarker.time : Infinity;
|
|
m.classList.toggle('active', t >= mTime && t < nextTime);
|
|
});
|
|
}
|
|
|
|
// Scrub bar: click track to seek
|
|
if (scrubTrack && video) {
|
|
let isDragging = false;
|
|
|
|
function getTimeFromEvent(e) {
|
|
const rect = scrubTrack.getBoundingClientRect();
|
|
const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
return pct * duration;
|
|
}
|
|
|
|
scrubTrack.addEventListener('mousedown', (e) => {
|
|
if (e.target.closest('.scrub-marker')) return;
|
|
isDragging = true;
|
|
const t = getTimeFromEvent(e);
|
|
video.currentTime = t;
|
|
updateScrubBar(t);
|
|
});
|
|
|
|
document.addEventListener('mousemove', (e) => {
|
|
if (!isDragging) return;
|
|
const t = getTimeFromEvent(e);
|
|
video.currentTime = t;
|
|
updateScrubBar(t);
|
|
});
|
|
|
|
document.addEventListener('mouseup', () => {
|
|
if (isDragging) {
|
|
isDragging = false;
|
|
video.play();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Scrub bar: marker hover tooltips
|
|
scrubMarkers.forEach(marker => {
|
|
marker.addEventListener('mouseenter', (e) => {
|
|
const idx = parseInt(marker.dataset.index);
|
|
const m = markers[idx];
|
|
if (!m || !scrubTooltip) return;
|
|
const action = m.action.length > 40 ? m.action.slice(0, 40) + '\u2026' : m.action;
|
|
scrubTooltip.innerHTML = '<span class="tooltip-icon">' + m.icon + '</span>' + action + '<span class="tooltip-time">' + formatTimeFn(m.time) + '</span>';
|
|
scrubTooltip.style.display = 'block';
|
|
|
|
const trackRect = scrubTrack.getBoundingClientRect();
|
|
const markerRect = marker.getBoundingClientRect();
|
|
const tooltipLeft = markerRect.left - trackRect.left + markerRect.width / 2;
|
|
scrubTooltip.style.left = tooltipLeft + 'px';
|
|
scrubTooltip.style.transform = 'translateX(-50%)';
|
|
});
|
|
|
|
marker.addEventListener('mouseleave', () => {
|
|
if (scrubTooltip) scrubTooltip.style.display = 'none';
|
|
});
|
|
|
|
marker.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const t = parseFloat(marker.dataset.time);
|
|
seekTo(t);
|
|
});
|
|
});
|
|
|
|
// Log lines with timestamps for video sync
|
|
const logLines = document.querySelectorAll('.log-line[data-time]');
|
|
|
|
// Highlight active log line for a given video time
|
|
function updateActiveLogLine(t) {
|
|
logLines.forEach(line => {
|
|
const lt = parseFloat(line.dataset.time);
|
|
const nextLine = line.nextElementSibling;
|
|
const hasNext = nextLine && nextLine.dataset && nextLine.dataset.time !== undefined;
|
|
const nextTime = hasNext ? parseFloat(nextLine.dataset.time) : Infinity;
|
|
line.classList.toggle('active', t >= lt && t < nextTime);
|
|
});
|
|
|
|
// Auto-scroll the active log line in the currently visible tab
|
|
if (activeTab === 'console' || activeTab === 'server') {
|
|
var tabId = activeTab === 'console' ? 'tabConsole' : 'tabServer';
|
|
var tabEl = document.getElementById(tabId);
|
|
if (tabEl) {
|
|
var activeLine = tabEl.querySelector('.log-line.active');
|
|
if (activeLine && timelinePanel) {
|
|
var panelRect = timelinePanel.getBoundingClientRect();
|
|
var lineRect = activeLine.getBoundingClientRect();
|
|
if (lineRect.top < panelRect.top || lineRect.bottom > panelRect.bottom) {
|
|
activeLine.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Highlight active step as video plays (only if video exists)
|
|
if (video) {
|
|
video.addEventListener('timeupdate', () => {
|
|
const t = video.currentTime;
|
|
let activeStep = null;
|
|
|
|
steps.forEach(step => {
|
|
const stepTime = parseFloat(step.dataset.time);
|
|
const nextStep = step.nextElementSibling;
|
|
const isLastStep = !nextStep || !nextStep.classList.contains('step');
|
|
const nextTime = isLastStep ? Infinity : parseFloat(nextStep.dataset.time);
|
|
const isActive = t >= stepTime && t < nextTime;
|
|
step.classList.toggle('active', isActive);
|
|
if (isActive) activeStep = step;
|
|
});
|
|
|
|
// Auto-scroll the active step into view (only when timeline tab is active)
|
|
if (activeStep && activeTab === 'timeline') {
|
|
const panelRect = timelinePanel.getBoundingClientRect();
|
|
const stepRect = activeStep.getBoundingClientRect();
|
|
if (stepRect.top < panelRect.top || stepRect.bottom > panelRect.bottom) {
|
|
activeStep.scrollIntoView({ block: 'center', behavior: 'smooth' });
|
|
}
|
|
}
|
|
|
|
// Sync log lines with video
|
|
updateActiveLogLine(t);
|
|
|
|
// Update scrub bar + markers
|
|
updateScrubBar(t);
|
|
updateActiveMarker(t);
|
|
});
|
|
|
|
// Sync scrub bar duration with actual video duration
|
|
video.addEventListener('loadedmetadata', () => {
|
|
if (video.duration && isFinite(video.duration)) {
|
|
duration = video.duration;
|
|
// Reposition markers to match actual video duration
|
|
scrubMarkers.forEach(m => {
|
|
const mTime = parseFloat(m.dataset.time);
|
|
m.style.left = (duration > 0 ? (mTime / duration) * 100 : 0) + '%';
|
|
});
|
|
}
|
|
});
|
|
|
|
// Start/stop rAF overlay loop with video play state
|
|
video.addEventListener('play', startOverlayLoop);
|
|
video.addEventListener('pause', stopOverlayLoop);
|
|
video.addEventListener('ended', stopOverlayLoop);
|
|
video.addEventListener('seeked', onSeeked);
|
|
}
|
|
|
|
// Keyboard navigation: left/right arrows jump between steps
|
|
document.addEventListener('keydown', (e) => {
|
|
if (activeTab !== 'timeline') return;
|
|
if (!video || !markers.length) return;
|
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
e.preventDefault();
|
|
const t = video.currentTime;
|
|
let targetIdx = -1;
|
|
|
|
if (e.key === 'ArrowRight') {
|
|
// Find next marker after current time
|
|
for (let i = 0; i < markers.length; i++) {
|
|
if (markers[i].time > t + 0.5) { targetIdx = i; break; }
|
|
}
|
|
if (targetIdx === -1) targetIdx = markers.length - 1;
|
|
} else {
|
|
// Find previous marker before current time
|
|
for (let i = markers.length - 1; i >= 0; i--) {
|
|
if (markers[i].time < t - 0.5) { targetIdx = i; break; }
|
|
}
|
|
if (targetIdx === -1) targetIdx = 0;
|
|
}
|
|
|
|
seekTo(markers[targetIdx].time);
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html> |