| Current Path : /var/www/html/onapp/interactive-videoplayer/ |
| Current File : /var/www/html/onapp/interactive-videoplayer/admin.html |
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Video Studio — Author</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap-icons/1.11.3/font/bootstrap-icons.min.css" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600&family=DM+Sans:wght@400;500;600;700&family=Inter:wght@400;600;700&family=Poppins:wght@400;600;700&family=Montserrat:wght@400;600;700&family=Playfair+Display:wght@400;700&family=Roboto:wght@400;700&family=Lora:wght@400;700&family=Pacifico&family=Bebas+Neue&display=swap" rel="stylesheet">
<style>
:root {
--bg-deep: #0a0a0f;
--bg-surface: #12121a;
--bg-card: #1a1a26;
--bg-elevated: #22222e;
--bg-hover: #2a2a38;
--border-dim: #2a2a3a;
--border-active: #4a4a6a;
--text-primary: #e8e8f0;
--text-secondary: #8888a0;
--text-dim: #5a5a72;
--accent-blue: #4a9eff;
--accent-blue-dim: rgba(74,158,255,0.15);
--accent-green: #3ddc84;
--accent-green-dim: rgba(61,220,132,0.15);
--accent-orange: #ff8a50;
--accent-orange-dim: rgba(255,138,80,0.15);
--accent-purple: #b47aff;
--accent-purple-dim: rgba(180,122,255,0.15);
--accent-red: #ff5a5a;
--accent-red-dim: rgba(255,90,90,0.15);
--accent-yellow: #ffd75a;
--font-body: 'DM Sans', sans-serif;
--font-mono: 'JetBrains Mono', monospace;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--shadow-float: 0 8px 32px rgba(0,0,0,0.5);
--transition-fast: 0.15s ease;
--transition-med: 0.25s ease;
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body { height:100%; overflow:hidden; background:var(--bg-deep); color:var(--text-primary); font-family:var(--font-body); font-size:14px; user-select:none; }
.app-layout { display:grid; grid-template-columns:260px 1fr 300px; grid-template-rows:48px 1fr 200px; height:100vh; }
.app-topbar { grid-column:1/-1; background:var(--bg-surface); border-bottom:1px solid var(--border-dim); display:flex; align-items:center; padding:0 16px; gap:12px; z-index:100; }
.app-sidebar-left { grid-row:2/4; background:var(--bg-surface); border-right:1px solid var(--border-dim); display:flex; flex-direction:column; overflow:hidden; }
.app-canvas { grid-row:2/3; background:var(--bg-deep); position:relative; overflow:hidden; }
.app-sidebar-right { grid-row:2/4; background:var(--bg-surface); border-left:1px solid var(--border-dim); overflow-y:auto; }
.app-timeline { grid-row:3/4; background:var(--bg-surface); border-top:1px solid var(--border-dim); display:flex; flex-direction:column; overflow:hidden; }
::-webkit-scrollbar { width:5px; height:5px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border-dim); border-radius:3px; }
.app-logo { font-family:var(--font-mono); font-weight:600; font-size:15px; color:var(--accent-blue); letter-spacing:-0.5px; white-space:nowrap; }
.app-logo span { color:var(--text-dim); font-weight:400; }
.topbar-divider { width:1px; height:24px; background:var(--border-dim); flex-shrink:0; }
.topbar-btn { background:none; border:1px solid var(--border-dim); color:var(--text-secondary); border-radius:var(--radius-sm); padding:6px 12px; font-size:12px; font-family:var(--font-body); cursor:pointer; transition:all var(--transition-fast); display:flex; align-items:center; gap:6px; white-space:nowrap; }
.topbar-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--border-active); }
.topbar-btn.primary { background:var(--accent-blue); border-color:var(--accent-blue); color:#fff; }
.topbar-btn.primary:hover { background:#3a8eef; }
.topbar-spacer { flex:1; }
.project-name { background:none; border:none; color:var(--text-primary); font-family:var(--font-body); font-size:14px; font-weight:500; padding:4px 8px; border-radius:var(--radius-sm); outline:none; width:180px; }
.project-name:focus { background:var(--bg-elevated); box-shadow:0 0 0 1px var(--accent-blue); }
.panel-header { padding:12px 14px 8px; font-size:11px; font-weight:600; text-transform:uppercase; letter-spacing:1px; color:var(--text-dim); display:flex; align-items:center; justify-content:space-between; flex-shrink:0; }
.panel-header .badge { background:var(--accent-blue); color:#fff; font-size:9px; padding:1px 6px; border-radius:10px; font-weight:600; }
.panel-content { padding:0 12px 12px; }
.lib-item {
display:flex; align-items:center; gap:10px; padding:8px 12px; cursor:grab;
border-bottom:1px solid var(--border-dim); transition:background var(--transition-fast);
}
.lib-item:hover { background:var(--bg-hover); }
.lib-item:active { cursor:grabbing; }
.lib-item .thumb { width:44px; height:30px; border-radius:4px; background:var(--bg-elevated); display:flex; align-items:center; justify-content:center; color:var(--text-dim); font-size:12px; overflow:hidden; flex-shrink:0; }
.lib-item .thumb video { width:100%; height:100%; object-fit:cover; }
.lib-item .info { flex:1; min-width:0; }
.lib-item .info .name { font-size:12px; font-weight:500; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-item .info .meta { font-size:10px; color:var(--text-dim); }
.lib-item .actions button { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:13px; padding:2px; }
.lib-item .actions button:hover { color:var(--accent-red); }
.add-zone { border:2px dashed var(--border-dim); border-radius:var(--radius-md); padding:16px; text-align:center; cursor:pointer; transition:all var(--transition-med); margin:8px 12px; flex-shrink:0; }
.add-zone:hover { border-color:var(--accent-blue); background:var(--accent-blue-dim); }
.add-zone i { font-size:20px; color:var(--text-dim); display:block; margin-bottom:4px; }
.add-zone span { font-size:11px; color:var(--text-secondary); }
.lib-list { flex:1; overflow-y:auto; }
.lib-hint { font-size:11px; color:var(--text-dim); padding:8px 14px; line-height:1.5; border-top:1px solid var(--border-dim); flex-shrink:0; }
.lib-hint i { color:var(--accent-blue); }
.drag-ghost {
position:fixed; pointer-events:none; z-index:9999;
background:var(--bg-card); border:2px solid var(--accent-blue); border-radius:var(--radius-md);
padding:8px 14px; font-size:12px; font-weight:600; color:var(--accent-blue);
box-shadow:var(--shadow-float); opacity:0.9; display:none;
}
.canvas-drop { position:absolute; inset:0; border:3px dashed var(--accent-blue); border-radius:12px; background:rgba(74,158,255,0.05); z-index:1; pointer-events:none; display:none; }
.canvas-grid { position:absolute; inset:0; pointer-events:none; opacity:0.03; background-image:radial-gradient(circle,var(--text-primary) 1px,transparent 1px); background-size:24px 24px; }
.canvas-controls { position:absolute; bottom:12px; left:50%; transform:translateX(-50%); display:flex; gap:4px; background:var(--bg-card); border:1px solid var(--border-dim); border-radius:var(--radius-md); padding:4px; z-index:20; }
.canvas-controls button { background:none; border:none; color:var(--text-secondary); cursor:pointer; width:30px; height:30px; border-radius:var(--radius-sm); display:flex; align-items:center; justify-content:center; font-size:15px; transition:all var(--transition-fast); }
.canvas-controls button:hover { background:var(--bg-hover); color:var(--text-primary); }
.canvas-zoom { font-size:10px; color:var(--text-dim); display:flex; align-items:center; padding:0 6px; font-family:var(--font-mono); }
.canvas-empty { position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; color:var(--text-dim); gap:10px; pointer-events:none; z-index:0; }
.canvas-empty i { font-size:40px; opacity:0.2; }
.canvas-empty .title { font-size:15px; font-weight:600; color:var(--text-secondary); }
.canvas-empty .desc { font-size:12px; text-align:center; line-height:1.5; max-width:260px; }
.conn-svg { position:absolute; inset:0; pointer-events:none; z-index:15; overflow:visible; }
.conn-svg path { fill:none; stroke-width:2.5; pointer-events:stroke; cursor:pointer; }
.conn-svg path.hit-area { stroke-width:16; stroke:transparent; pointer-events:stroke; cursor:pointer; }
/* ====== VIDEO NODE ====== */
.vnode {
position:absolute; width:220px; background:var(--bg-card); border:2px solid var(--border-dim);
border-radius:var(--radius-lg); z-index:5; transition:box-shadow var(--transition-fast), border-color var(--transition-fast);
}
.vnode:hover { border-color:var(--border-active); }
.vnode.selected { border-color:var(--accent-blue); box-shadow:0 0 0 3px var(--accent-blue-dim), 0 4px 20px rgba(0,0,0,0.4); z-index:10; }
.vnode.start-node { border-color:var(--accent-green); }
.vnode.start-node .vnode-badge { background:var(--accent-green); }
.vnode-preview {
width:100%; height:90px; background:var(--bg-elevated); border-radius:12px 12px 0 0;
overflow:hidden; position:relative; display:flex; align-items:center; justify-content:center;
cursor:grab;
}
.vnode-preview:active { cursor:grabbing; }
.vnode-preview video { width:100%; height:100%; object-fit:cover; pointer-events:none; }
.vnode-preview .preview-placeholder { font-size:24px; color:var(--text-dim); opacity:0.4; }
.vnode-header {
display:flex; align-items:center; gap:8px; padding:8px 10px;
border-bottom:1px solid var(--border-dim); cursor:grab; position:relative;
}
.vnode-header:active { cursor:grabbing; }
.vnode-badge { background:var(--accent-blue); color:#fff; font-size:8px; font-weight:700; padding:2px 6px; border-radius:8px; text-transform:uppercase; letter-spacing:0.5px; flex-shrink:0; }
.vnode-name { flex:1; font-size:12px; font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.vnode-dur { font-size:10px; color:var(--text-dim); font-family:var(--font-mono); flex-shrink:0; }
.vnode-ports-row {
display:flex; align-items:center; justify-content:space-between; padding:6px 10px;
border-bottom:1px solid var(--border-dim); min-height:30px;
}
.port {
width:14px; height:14px; border-radius:50%; border:2px solid var(--border-active);
background:var(--bg-card); cursor:crosshair; transition:all var(--transition-fast); flex-shrink:0;
}
.port:hover { transform:scale(1.3); }
.port.port-in { border-color:var(--accent-blue); }
.port.port-in:hover { background:var(--accent-blue); }
.port.port-out { border-color:var(--accent-green); }
.port.port-out:hover { background:var(--accent-green); }
.port.port-out.connected { background:var(--accent-green); }
.port-label { font-size:9px; color:var(--text-dim); text-transform:uppercase; letter-spacing:0.5px; }
.port-row-center { flex:1; text-align:center; font-size:10px; color:var(--text-dim); }
.vnode-interactions { padding:0; }
.vnode-int-item {
display:flex; align-items:center; gap:6px; padding:5px 10px;
border-bottom:1px solid rgba(42,42,58,0.5); font-size:11px; transition:background var(--transition-fast);
cursor:pointer; position:relative;
}
.vnode-int-item:hover { background:var(--bg-hover); }
.vnode-int-item.selected { background:var(--accent-blue-dim); }
.int-icon { width:18px; height:18px; border-radius:4px; display:flex; align-items:center; justify-content:center; font-size:10px; flex-shrink:0; }
.int-icon.hotspot { background:var(--accent-orange-dim); color:var(--accent-orange); }
.int-icon.choice { background:var(--accent-green-dim); color:var(--accent-green); }
.int-icon.button { background:var(--accent-blue-dim); color:var(--accent-blue); }
.int-icon.overlay { background:var(--accent-purple-dim); color:var(--accent-purple); }
.int-icon.timer { background:rgba(255,215,90,0.15); color:var(--accent-yellow); }
.vnode-int-label { flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.vnode-int-time { font-size:9px; color:var(--text-dim); font-family:var(--font-mono); white-space:nowrap; flex-shrink:0; }
.vnode-int-item .port { width:12px; height:12px; }
.del-int { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:11px; padding:0 2px; opacity:0; transition:opacity var(--transition-fast); }
.vnode-int-item:hover .del-int { opacity:1; }
.del-int:hover { color:var(--accent-red); }
.vnode-add-int {
padding:6px 10px; text-align:center; font-size:10px; color:var(--text-dim);
cursor:pointer; transition:all var(--transition-fast); border-radius:0 0 12px 12px;
}
.vnode-add-int:hover { background:var(--bg-hover); color:var(--accent-blue); }
/* Context menu */
.ctx-menu { position:fixed; background:var(--bg-card); border:1px solid var(--border-dim); border-radius:var(--radius-md); padding:4px; z-index:500; min-width:160px; box-shadow:var(--shadow-float); display:none; }
.ctx-menu.show { display:block; }
.ctx-item { padding:7px 12px; font-size:12px; cursor:pointer; border-radius:var(--radius-sm); display:flex; align-items:center; gap:8px; transition:background var(--transition-fast); }
.ctx-item:hover { background:var(--bg-hover); }
.ctx-item i { width:16px; text-align:center; color:var(--text-dim); }
.ctx-sep { height:1px; background:var(--border-dim); margin:4px 0; }
.ctx-item.danger { color:var(--accent-red); }
.ctx-item.danger i { color:var(--accent-red); }
/* Properties */
.props-empty { display:flex; flex-direction:column; align-items:center; justify-content:center; height:100%; color:var(--text-dim); gap:10px; padding:20px; }
.props-empty i { font-size:28px; opacity:0.3; }
.props-empty .title { font-size:13px; font-weight:600; color:var(--text-secondary); }
.prop-group { margin-bottom:14px; }
.prop-group-title { font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:0.8px; color:var(--text-dim); margin-bottom:6px; }
.prop-field { margin-bottom:8px; }
.prop-field label { display:block; font-size:11px; color:var(--text-secondary); margin-bottom:3px; font-weight:500; }
.prop-field input, .prop-field select, .prop-field textarea {
width:100%; background:var(--bg-elevated); border:1px solid var(--border-dim);
border-radius:var(--radius-sm); padding:6px 8px; color:var(--text-primary);
font-family:var(--font-body); font-size:12px; outline:none; transition:border-color var(--transition-fast);
}
.prop-field input:focus, .prop-field select:focus { border-color:var(--accent-blue); }
.prop-check { display:flex; align-items:center; gap:6px; cursor:pointer; font-size:12px; }
.prop-check input { width:14px; height:14px; accent-color:var(--accent-blue); }
.prop-btn { width:100%; padding:7px; border-radius:var(--radius-sm); border:1px solid var(--border-dim); background:var(--bg-card); color:var(--text-secondary); font-family:var(--font-body); font-size:11px; cursor:pointer; transition:all var(--transition-fast); display:flex; align-items:center; justify-content:center; gap:4px; }
.prop-btn:hover { background:var(--bg-hover); color:var(--text-primary); border-color:var(--border-active); }
.prop-btn.danger { border-color:var(--accent-red); color:var(--accent-red); }
.prop-btn.danger:hover { background:var(--accent-red-dim); }
/* Timeline */
.tl-header { display:flex; align-items:center; gap:10px; padding:8px 14px; border-bottom:1px solid var(--border-dim); flex-shrink:0; }
.tl-title { font-size:12px; font-weight:600; color:var(--text-secondary); flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.tl-body { flex:1; overflow:auto; position:relative; }
.tl-empty { display:flex; align-items:center; justify-content:center; height:100%; font-size:12px; color:var(--text-dim); }
.tl-ruler { height:22px; background:var(--bg-card); border-bottom:1px solid var(--border-dim); position:sticky; top:0; z-index:3; }
.tl-tick { position:absolute; top:0; height:100%; border-left:1px solid var(--border-dim); }
.tl-tick-label { position:absolute; top:4px; left:4px; font-size:8px; color:var(--text-dim); font-family:var(--font-mono); white-space:nowrap; }
.tl-tracks { position:relative; padding:4px 0; }
.tl-track { height:28px; position:relative; margin-bottom:2px; }
.tl-bar {
position:absolute; height:22px; top:3px; border-radius:4px; cursor:grab;
display:flex; align-items:center; padding:0 8px; font-size:9px; font-weight:600;
overflow:hidden; white-space:nowrap; min-width:20px;
transition:box-shadow var(--transition-fast);
}
.tl-bar:hover { box-shadow:0 0 0 2px rgba(255,255,255,0.2); }
.tl-bar.selected { box-shadow:0 0 0 2px var(--accent-blue); }
.tl-bar.type-hotspot { background:var(--accent-orange-dim); border:1px solid rgba(255,138,80,0.5); color:var(--accent-orange); }
.tl-bar.type-button { background:var(--accent-blue-dim); border:1px solid rgba(74,158,255,0.5); color:var(--accent-blue); }
.tl-bar.type-choice { background:var(--accent-green-dim); border:1px solid rgba(61,220,132,0.5); color:var(--accent-green); }
.tl-bar.type-overlay { background:var(--accent-purple-dim); border:1px solid rgba(180,122,255,0.5); color:var(--accent-purple); }
.tl-bar.type-timer { background:rgba(255,215,90,0.15); border:1px solid rgba(255,215,90,0.5); color:var(--accent-yellow); }
.tl-handle { position:absolute; top:0; bottom:0; width:7px; cursor:col-resize; z-index:2; }
.tl-handle-l { left:0; border-radius:4px 0 0 4px; }
.tl-handle-r { right:0; border-radius:0 4px 4px 0; }
.tl-handle:hover { background:rgba(255,255,255,0.15); }
/* Toast */
.toast-box { position:fixed; top:56px; right:16px; z-index:2000; display:flex; flex-direction:column; gap:6px; }
.toast-msg { background:var(--bg-card); border:1px solid var(--border-dim); border-radius:var(--radius-md); padding:8px 14px; font-size:12px; display:flex; align-items:center; gap:6px; box-shadow:var(--shadow-float); animation:toastIn 0.3s ease; }
.toast-msg.ok { border-color:var(--accent-green); }
.toast-msg.ok i { color:var(--accent-green); }
.toast-msg.err { border-color:var(--accent-red); }
.toast-msg.err i { color:var(--accent-red); }
@keyframes toastIn { from{opacity:0;transform:translateX(20px)} to{opacity:1;transform:translateX(0)} }
/* Modal */
.modal-bg { position:fixed; inset:0; background:rgba(0,0,0,0.7); z-index:1000; display:none; align-items:center; justify-content:center; backdrop-filter:blur(4px); }
.modal-bg.show { display:flex; }
.modal-box { background:var(--bg-card); border:1px solid var(--border-dim); border-radius:var(--radius-lg); width:480px; max-width:92vw; max-height:80vh; overflow-y:auto; box-shadow:var(--shadow-float); }
.modal-head { padding:14px 18px; border-bottom:1px solid var(--border-dim); display:flex; align-items:center; justify-content:space-between; }
.modal-head h3 { font-size:14px; font-weight:600; }
.modal-head button { background:none; border:none; color:var(--text-dim); cursor:pointer; font-size:16px; }
.modal-body { padding:16px 18px; }
.modal-foot { padding:10px 18px; border-top:1px solid var(--border-dim); display:flex; justify-content:flex-end; gap:8px; }
.tab-bar { display:flex; border-bottom:1px solid var(--border-dim); flex-shrink:0; }
.tab-bar .tab { padding:8px 14px; font-size:11px; font-weight:500; color:var(--text-dim); cursor:pointer; border-bottom:2px solid transparent; transition:all var(--transition-fast); }
.tab-bar .tab:hover { color:var(--text-secondary); }
.tab-bar .tab.active { color:var(--accent-blue); border-bottom-color:var(--accent-blue); }
.settings-scroll { padding:12px; overflow-y:auto; flex:1; }
/* LIGHT THEME */
body.light {
--bg-deep: #f0f1f5;
--bg-surface: #ffffff;
--bg-card: #f8f9fb;
--bg-elevated: #eef0f4;
--bg-hover: #e4e6ec;
--border-dim: #d4d6de;
--border-active: #b0b4c0;
--text-primary: #1a1a2e;
--text-secondary: #5a5c72;
--text-dim: #8a8ca0;
--accent-blue: #2b7de9;
--accent-blue-dim: rgba(43,125,233,0.12);
--accent-green: #2db86a;
--accent-green-dim: rgba(45,184,106,0.12);
--accent-orange: #e06830;
--accent-orange-dim: rgba(224,104,48,0.12);
--accent-purple: #8a52d6;
--accent-purple-dim: rgba(138,82,214,0.12);
--accent-red: #e04040;
--accent-red-dim: rgba(224,64,64,0.12);
--accent-yellow: #c8a020;
--shadow-float: 0 8px 32px rgba(0,0,0,0.12);
}
body.light .canvas-grid { opacity:0.06; }
body.light .vnode { box-shadow:0 2px 8px rgba(0,0,0,0.08); }
body.light .conn-svg path { opacity:0.8; }
</style>
</head>
<body>
<div class="app-layout">
<div class="app-topbar">
<div class="app-logo">IVS<span>.studio</span></div>
<div class="topbar-divider"></div>
<input type="text" class="project-name" id="projectName" value="Untitled Project" spellcheck="false">
<div class="topbar-divider"></div>
<button class="topbar-btn" onclick="doUndo()" title="Ctrl+Z"><i class="bi bi-arrow-counterclockwise"></i></button>
<button class="topbar-btn" onclick="doRedo()" title="Ctrl+Y"><i class="bi bi-arrow-clockwise"></i></button>
<div class="topbar-spacer"></div>
<button class="topbar-btn" onclick="previewProject()"><i class="bi bi-play-circle"></i> Preview</button>
<button class="topbar-btn" onclick="showImportModal()"><i class="bi bi-upload"></i> Import</button>
<button class="topbar-btn primary" onclick="exportProject()"><i class="bi bi-download"></i> Export</button>
<div class="topbar-divider"></div>
<button class="topbar-btn" id="themeBtn" onclick="toggleTheme()" title="Toggle light/dark theme"><i class="bi bi-sun" id="themeIcon"></i></button>
</div>
<div class="app-sidebar-left">
<div class="tab-bar">
<div class="tab active" data-tab="library" onclick="switchTab(this)">Library</div>
<div class="tab" data-tab="settings" onclick="switchTab(this)">Settings</div>
</div>
<div id="tab-library" style="display:flex;flex-direction:column;flex:1;overflow:hidden;">
<div class="panel-header">Videos <span class="badge" id="libCount">0</span></div>
<div class="add-zone" id="addZone" onclick="document.getElementById('fileInput').click()">
<i class="bi bi-cloud-arrow-up"></i><span>Drop or click to add videos</span>
</div>
<button class="prop-btn" style="margin:0 12px 8px;background:var(--accent-blue-dim);border-color:rgba(74,158,255,0.3);color:var(--accent-blue)" onclick="openServerBrowser()"><i class="bi bi-folder2-open"></i> Browse Server Videos</button>
<button class="prop-btn" style="margin:0 12px 8px" onclick="rescanLibraryPaths()" title="Rebuild all library paths using current base path"><i class="bi bi-arrow-repeat"></i> Rebuild Paths from Base</button>
<input type="file" id="fileInput" accept="video/*" multiple hidden onchange="addToLib(this.files)">
<div class="lib-list" id="libList"></div>
<div class="lib-hint"><i class="bi bi-info-circle"></i> Drag videos from here onto the canvas. Same video can be placed multiple times.</div>
</div>
<div id="tab-settings" style="display:none;flex:1;overflow:hidden;">
<div class="settings-scroll">
<div class="prop-group">
<div class="prop-group-title">Player Controls</div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowCtrl" checked onchange="saveSettings()"> Show controls</label></div>
<div class="prop-field"><label>Control Mode</label><select id="sCtrlMode" onchange="saveSettings();toggleCtrlUI()">
<option value="standard">Standard (bar layout)</option>
<option value="custom">Customized (free-position buttons)</option>
</select></div>
<div id="standardCtrlOpts">
<div class="prop-field"><label>Bar Position</label><select id="sCtrlPos" onchange="saveSettings()">
<option value="bottom">Bottom</option>
<option value="top">Top</option>
<option value="topLeft">Top Left (floating)</option>
<option value="topRight">Top Right (floating)</option>
<option value="bottomLeft">Bottom Left (floating)</option>
<option value="bottomRight">Bottom Right (floating)</option>
</select></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sAutoHide" checked onchange="saveSettings()"> Auto-hide controls</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sSeek" checked onchange="saveSettings()"> Allow seeking</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sProgress" checked onchange="saveSettings()"> Show progress bar</label></div>
</div>
<div id="customCtrlOpts" style="display:none">
<div style="font-size:10px;color:var(--text-dim);margin-bottom:8px;line-height:1.4">Position each control button freely on screen using the visual editor.</div>
<button class="prop-btn" style="margin-bottom:8px;background:var(--accent-blue-dim);border-color:rgba(74,158,255,0.3);color:var(--accent-blue)" onclick="openCtrlPosEditor()"><i class="bi bi-bounding-box"></i> Position Controls Visually</button>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowPlay" checked onchange="saveSettings()"> Show Play button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowPause" checked onchange="saveSettings()"> Show Pause button (toggles with Play)</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowRewind" checked onchange="saveSettings()"> Show Rewind button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowForward" checked onchange="saveSettings()"> Show Forward button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowMute" checked onchange="saveSettings()"> Show Mute/Volume button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowFs" checked onchange="saveSettings()"> Show Fullscreen button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sShowReplay" checked onchange="saveSettings()"> Show Replay button</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sProgress2" checked onchange="saveSettings()"> Show progress bar</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sAutoHide2" checked onchange="saveSettings()"> Auto-hide controls</label></div>
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Control Button Icons</div>
<div style="font-size:10px;color:var(--text-dim);margin-bottom:8px">Upload custom images for each control button. Leave empty to use default icons.</div>
<div class="prop-field"><label>Play</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sPlayFile').click()"><i class="bi bi-upload"></i></button>
<span id="sPlayPreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('play')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sPlayFile" accept="image/*" hidden onchange="handleIconUpload(this,'play')"><input type="hidden" id="sPlayIcon" value=""></div>
<div class="prop-field"><label>Pause</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sPauseFile').click()"><i class="bi bi-upload"></i></button>
<span id="sPausePreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('pause')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sPauseFile" accept="image/*" hidden onchange="handleIconUpload(this,'pause')"><input type="hidden" id="sPauseIcon" value=""></div>
<div class="prop-field"><label>Seek (Rewind/Forward)</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sSeekFile').click()"><i class="bi bi-upload"></i></button>
<span id="sSeekPreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('seek')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sSeekFile" accept="image/*" hidden onchange="handleIconUpload(this,'seek')"><input type="hidden" id="sSeekIcon" value=""></div>
<div class="prop-field"><label>Mute/Volume</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sMuteFile').click()"><i class="bi bi-upload"></i></button>
<span id="sMutePreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('mute')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sMuteFile" accept="image/*" hidden onchange="handleIconUpload(this,'mute')"><input type="hidden" id="sMuteIcon" value=""></div>
<div class="prop-field"><label>Fullscreen</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sFsFile').click()"><i class="bi bi-upload"></i></button>
<span id="sFsPreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('fs')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sFsFile" accept="image/*" hidden onchange="handleIconUpload(this,'fs')"><input type="hidden" id="sFsIcon" value=""></div>
<div class="prop-field"><label>Replay</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sReplayFile').click()"><i class="bi bi-upload"></i></button>
<span id="sReplayPreview" style="font-size:10px;color:var(--text-dim)">Default</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearIconUpload('replay')"><i class="bi bi-x"></i></button>
</div><input type="file" id="sReplayFile" accept="image/*" hidden onchange="handleIconUpload(this,'replay')"><input type="hidden" id="sReplayIcon" value=""></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Overlay Image</div>
<div style="font-size:10px;color:var(--text-dim);margin-bottom:8px">Image layer between video and controls (e.g. frame, border, vignette)</div>
<div class="prop-field"><label>Image</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('sOvImgFile').click()"><i class="bi bi-upload"></i></button>
<span id="sOvImgPreview" style="font-size:10px;color:var(--text-dim)">None</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="clearOverlayImg()"><i class="bi bi-x"></i></button>
</div><input type="file" id="sOvImgFile" accept="image/*" hidden onchange="handleOverlayImg(this)"><input type="hidden" id="sOvImg" value=""></div>
<div class="prop-field"><label>Size</label><select id="sOvImgSize" onchange="saveSettings()">
<option value="cover">Cover (fill)</option>
<option value="contain">Contain (fit)</option>
<option value="100% 100%">Stretch</option>
<option value="auto">Original size</option>
</select></div>
<div class="prop-field"><label>Position</label><select id="sOvImgPos" onchange="saveSettings()">
<option value="center">Center</option>
<option value="top">Top</option>
<option value="bottom">Bottom</option>
<option value="left">Left</option>
<option value="right">Right</option>
</select></div>
<div class="prop-field"><label>Opacity</label><input type="range" id="sOvImgOp" min="0" max="1" step="0.05" value="1" style="width:100%" onchange="saveSettings()"></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Accent Color</div>
<div class="prop-field"><input type="color" id="sAccent" value="#4a9eff" onchange="saveSettings()"></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Playback</div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sAutoPlay" checked onchange="saveSettings()"> Auto-play on load (uncheck to show play button first)</label></div>
</div>
<div class="prop-group">
<div class="prop-group-title">End Behavior</div>
<div class="prop-field"><select id="sEnd" onchange="saveSettings()">
<option value="pause">Pause on last frame</option>
<option value="loop">Loop video</option>
<option value="restart">Restart from start</option>
</select></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Video Display</div>
<div class="prop-field"><label>Video Fit Mode</label><select id="sVideoFit" onchange="saveSettings()">
<option value="contain">Fit (contain, show bars)</option>
<option value="cover">Cover (fill, crop edges)</option>
<option value="fill">Stretch to fill</option>
</select></div>
<div class="prop-field"><label>Video Base Path</label><input type="text" id="sVideoBase" value="./videos/" placeholder="./videos/" onchange="saveSettings()">
<div style="font-size:9px;color:var(--text-dim);margin-top:2px">Relative path prefix for all video files (e.g. ./videos/ or ./media/clips/)</div>
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Choice Overlay</div>
<div class="prop-field"><label>Title Text</label><input type="text" id="sChoiceTitle" value="What do you choose?" onchange="saveSettings()"></div>
<div class="prop-field"><label>Subtitle Text</label><input type="text" id="sChoiceSub" value="Select an option to continue" onchange="saveSettings()"></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="sChoiceShow" checked onchange="saveSettings()"> Show choice overlay (uncheck to auto-play if single connection)</label></div>
<div class="prop-field"><label>Replay Icon (shown at end)</label>
<div style="display:flex;gap:6px;align-items:center">
<button class="prop-btn" style="width:auto;padding:4px 10px" onclick="document.getElementById('sReplayFile').click()"><i class="bi bi-upload"></i> Upload</button>
<span id="sReplayPreview" style="font-size:10px;color:var(--text-dim)">Default icon</span>
<button class="prop-btn" style="width:auto;padding:4px 8px" onclick="clearIconUpload('replay')" title="Reset"><i class="bi bi-x"></i></button>
</div>
<input type="file" id="sReplayFile" accept="image/*" hidden onchange="handleIconUpload(this,'replay')">
<input type="hidden" id="sReplayIcon" value="">
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Loading Screen</div>
<div class="prop-field"><label>Loading Text</label><input type="text" id="sLoadText" value="Loading..." onchange="saveSettings()"></div>
<div class="prop-field"><label>Loading Background</label><input type="color" id="sLoadBg" value="#000000" onchange="saveSettings()"></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Audio</div>
<div class="prop-field"><label>Audio Source</label><select id="sAudioSrc" onchange="saveSettings()">
<option value="video">Play audio from videos</option>
<option value="bgm">Play background music (loop)</option>
<option value="both">Both video audio + background music</option>
<option value="mute">Mute all</option>
</select></div>
<div class="prop-field"><label>Background Music Path</label><input type="text" id="sAudioFile" placeholder="./audio/bgm.mp3" onchange="saveSettings()">
<div style="font-size:9px;color:var(--text-dim);margin-top:2px">Relative path to MP3 file (loops continuously)</div>
</div>
<div class="prop-field"><label>BGM Volume</label><input type="range" id="sAudioVol" min="0" max="1" step="0.1" value="0.5" style="width:100%" onchange="saveSettings()"></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Transitions</div>
<div class="prop-field"><label>Default Transition</label><select id="sTransition" onchange="saveSettings()">
<option value="cut">Cut (instant)</option>
<option value="fade">Fade to black</option>
<option value="fadeWhite">Fade to white</option>
<option value="dissolve">Dissolve (crossfade)</option>
<option value="slideLeft">Slide Left</option>
<option value="slideRight">Slide Right</option>
<option value="zoomIn">Zoom In</option>
<option value="blur">Blur</option>
</select></div>
<div class="prop-field"><label>Duration (ms)</label><input type="number" id="sTransDur" value="600" min="0" max="3000" step="100" onchange="saveSettings()"></div>
</div>
</div>
</div>
</div>
<div class="app-canvas" id="canvas">
<div class="canvas-grid"></div>
<div class="canvas-drop" id="dropInd"></div>
<svg class="conn-svg" id="connSvg"></svg>
<div id="nodeLayer"></div>
<div class="canvas-empty" id="canvasEmpty">
<i class="bi bi-film"></i>
<div class="title">Drag videos here</div>
<div class="desc">Drag from the library to build your interactive story. Connect OUT ports to IN ports.</div>
</div>
<div class="canvas-controls">
<button onclick="doZoom(-0.1)"><i class="bi bi-dash"></i></button>
<div class="canvas-zoom" id="zoomLbl">100%</div>
<button onclick="doZoom(0.1)"><i class="bi bi-plus"></i></button>
<button onclick="fitView()"><i class="bi bi-fullscreen"></i></button>
</div>
</div>
<div class="app-sidebar-right" id="rightPanel">
<div class="props-empty" id="propsEmpty">
<i class="bi bi-sliders"></i>
<div class="title">Properties</div>
<div style="font-size:11px;text-align:center;color:var(--text-dim)">Select a node, interaction, or connection</div>
</div>
<div id="propsArea" style="display:none;">
<div class="panel-header"><span id="propsTitle">Properties</span></div>
<div class="panel-content" id="propsContent"></div>
</div>
</div>
<div class="app-timeline">
<div class="tl-header">
<div class="tl-title" id="tlTitle">Timeline — select a node</div>
<div class="topbar-spacer"></div>
<button class="topbar-btn" style="padding:4px 6px;font-size:11px" onclick="tlZoom(-4)" title="Zoom out timeline"><i class="bi bi-dash"></i></button>
<span style="font-size:10px;color:var(--text-dim);font-family:var(--font-mono)" id="tlZoomLbl">18px/s</span>
<button class="topbar-btn" style="padding:4px 6px;font-size:11px" onclick="tlZoom(4)" title="Zoom in timeline"><i class="bi bi-plus"></i></button>
<button class="topbar-btn" id="btnAddInt" style="display:none" onclick="openAddInt()"><i class="bi bi-plus"></i> Add</button>
</div>
<div class="tl-body" id="tlBody">
<div class="tl-empty" id="tlEmpty">Select a video node to see its timeline</div>
<div id="tlContent" style="display:none;height:100%;">
<div class="tl-ruler" id="tlRuler"></div>
<div class="tl-tracks" id="tlTracks"></div>
</div>
</div>
</div>
</div>
<div class="drag-ghost" id="dragGhost"></div>
<div class="ctx-menu" id="ctxMenu">
<div class="ctx-item" onclick="ctxAct('start')"><i class="bi bi-play-fill"></i> Set as Start</div>
<div class="ctx-item" onclick="ctxAct('dup')"><i class="bi bi-copy"></i> Duplicate</div>
<div class="ctx-sep"></div>
<div class="ctx-item danger" onclick="ctxAct('del')"><i class="bi bi-trash3"></i> Delete Node</div>
</div>
<div class="modal-bg" id="modalInt">
<div class="modal-box">
<div class="modal-head"><h3 id="miTitle">Add Interaction</h3><button onclick="closeMod('modalInt')"><i class="bi bi-x-lg"></i></button></div>
<div class="modal-body" id="miBody"></div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalInt')">Cancel</button>
<button class="topbar-btn primary" onclick="saveInt()">Save</button>
</div>
</div>
</div>
<div class="modal-bg" id="modalImport">
<div class="modal-box">
<div class="modal-head"><h3>Import Project</h3><button onclick="closeMod('modalImport')"><i class="bi bi-x-lg"></i></button></div>
<div class="modal-body">
<div class="prop-field"><label>Paste JSON or load file</label><textarea id="importJson" rows="8" style="font-family:var(--font-mono);font-size:11px;"></textarea></div>
<div class="add-zone" onclick="document.getElementById('importFile').click()" style="margin:0"><i class="bi bi-file-code"></i><span>Load .json file</span></div>
<input type="file" id="importFile" accept=".json" hidden onchange="loadImpFile(this.files[0])">
</div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalImport')">Cancel</button>
<button class="topbar-btn primary" onclick="doImport()">Import</button>
</div>
</div>
</div>
<!-- POSITION EDITOR MODAL -->
<div class="modal-bg" id="modalPos">
<div class="modal-box" style="width:860px;max-width:96vw;max-height:92vh;">
<div class="modal-head">
<h3>Position Editor — <span id="posTitle">Element</span></h3>
<button onclick="closeMod('modalPos')"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body" style="padding:12px;">
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
<button class="topbar-btn" id="vpDesk" onclick="switchVP('desktop')" style="font-size:11px"><i class="bi bi-display"></i> Desktop 1920×1080</button>
<button class="topbar-btn" id="vpTab" onclick="switchVP('tablet')" style="font-size:11px"><i class="bi bi-tablet"></i> Tablet 1024×768</button>
<button class="topbar-btn" id="vpMobP" onclick="switchVP('mobileP')" style="font-size:11px"><i class="bi bi-phone"></i> Mobile 390×844</button>
<button class="topbar-btn" id="vpMobL" onclick="switchVP('mobileL')" style="font-size:11px"><i class="bi bi-phone-landscape"></i> Landscape 844×390</button>
</div>
<div style="position:relative;background:var(--bg-elevated);border-radius:8px;overflow:hidden;border:1px solid var(--border-dim);" id="posViewport">
<div id="posFrame" style="position:relative;margin:0 auto;background:#111;overflow:hidden;">
<div id="posDragEl" style="position:absolute;cursor:move;border:2px dashed var(--accent-blue);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;z-index:5;">
<div class="pos-handle pos-h-tl" style="position:absolute;top:-6px;left:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:nw-resize;z-index:6"></div>
<div class="pos-handle pos-h-tr" style="position:absolute;top:-6px;right:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:ne-resize;z-index:6"></div>
<div class="pos-handle pos-h-bl" style="position:absolute;bottom:-6px;left:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:sw-resize;z-index:6"></div>
<div class="pos-handle pos-h-br" style="position:absolute;bottom:-6px;right:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:se-resize;z-index:6"></div>
<div class="pos-handle pos-h-tm" style="position:absolute;top:-6px;left:50%;margin-left:-7px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:n-resize;z-index:6"></div>
<div class="pos-handle pos-h-bm" style="position:absolute;bottom:-6px;left:50%;margin-left:-7px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:s-resize;z-index:6"></div>
<div class="pos-handle pos-h-ml" style="position:absolute;top:50%;margin-top:-7px;left:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:w-resize;z-index:6"></div>
<div class="pos-handle pos-h-mr" style="position:absolute;top:50%;margin-top:-7px;right:-6px;width:14px;height:14px;background:#fff;border:2px solid var(--accent-blue);border-radius:3px;cursor:e-resize;z-index:6"></div>
</div>
<div style="position:absolute;bottom:4px;left:4px;font-size:9px;color:rgba(255,255,255,0.4);z-index:6;" id="posCoords">x:50% y:50%</div>
</div>
</div>
<div style="display:flex;gap:8px;margin-top:10px;flex-wrap:wrap;align-items:end;">
<div class="prop-field" style="flex:1;min-width:60px;margin:0"><label>X</label><input type="number" id="posX" min="0" step="1" onchange="updatePosFromInput()"></div>
<div class="prop-field" style="flex:1;min-width:60px;margin:0"><label>Y</label><input type="number" id="posY" min="0" step="1" onchange="updatePosFromInput()"></div>
<div class="prop-field" style="flex:1;min-width:60px;margin:0"><label>W</label><input type="number" id="posW" min="1" step="1" onchange="updatePosFromInput()"></div>
<div class="prop-field" style="flex:1;min-width:60px;margin:0"><label>H</label><input type="number" id="posH" min="1" step="1" onchange="updatePosFromInput()"></div>
<button class="topbar-btn" id="posUnitBtn" onclick="togglePosUnit()" style="margin-bottom:0;height:32px;font-weight:700;min-width:40px">%</button>
</div>
</div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalPos')">Cancel</button>
<button class="topbar-btn primary" onclick="savePosEditor()">Apply</button>
</div>
</div>
</div>
<!-- LIBRARY PREVIEW MODAL -->
<div class="modal-bg" id="modalLibPrev">
<div class="modal-box" style="width:640px;max-width:96vw;">
<div class="modal-head">
<h3 id="libPrevTitle">Video Preview</h3>
<button onclick="closeMod('modalLibPrev')"><i class="bi bi-x-lg"></i></button>
</div>
<div class="modal-body" id="libPrevBody"></div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalLibPrev')">Close</button>
</div>
</div>
</div>
<!-- SERVER BROWSER MODAL -->
<div class="modal-bg" id="modalServer">
<div class="modal-box" style="width:600px;">
<div class="modal-head"><h3>Browse Server Videos</h3><button onclick="closeMod('modalServer')"><i class="bi bi-x-lg"></i></button></div>
<div class="modal-body">
<div class="prop-field">
<label>Server Directory URL</label>
<div style="display:flex;gap:6px">
<input type="text" id="srvPath" value="" placeholder="./videos/" style="flex:1">
<button class="topbar-btn primary" onclick="scanServerDir()"><i class="bi bi-search"></i> Scan</button>
</div>
<div style="font-size:9px;color:var(--text-dim);margin-top:2px">Enter the directory path. Server must have directory listing enabled or provide a JSON index.</div>
</div>
<div id="srvBreadcrumb" style="font-size:11px;color:var(--text-dim);margin-bottom:8px;display:flex;gap:4px;flex-wrap:wrap"></div>
<div id="srvList" style="max-height:350px;overflow-y:auto;border:1px solid var(--border-dim);border-radius:var(--radius-md);background:var(--bg-elevated)">
<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:12px">Enter a path and click Scan to browse videos</div>
</div>
</div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalServer')">Close</button>
</div>
</div>
</div>
<!-- CONTROL POSITION EDITOR MODAL -->
<div class="modal-bg" id="modalCtrlPos">
<div class="modal-box" style="width:900px;max-width:96vw;max-height:94vh;">
<div class="modal-head"><h3>Position Control Buttons</h3><button onclick="closeMod('modalCtrlPos')"><i class="bi bi-x-lg"></i></button></div>
<div class="modal-body" style="padding:12px;">
<div style="display:flex;gap:8px;margin-bottom:10px;flex-wrap:wrap;">
<button class="topbar-btn" id="cvpDesk" onclick="switchCtrlVP('desktop')" style="font-size:11px"><i class="bi bi-display"></i> Desktop</button>
<button class="topbar-btn" id="cvpTab" onclick="switchCtrlVP('tablet')" style="font-size:11px"><i class="bi bi-tablet"></i> Tablet</button>
<button class="topbar-btn" id="cvpMobP" onclick="switchCtrlVP('mobileP')" style="font-size:11px"><i class="bi bi-phone"></i> Mobile</button>
<button class="topbar-btn" id="cvpMobL" onclick="switchCtrlVP('mobileL')" style="font-size:11px"><i class="bi bi-phone-landscape"></i> Landscape</button>
<div style="flex:1"></div>
<span style="font-size:10px;color:var(--text-dim);display:flex;align-items:center" id="ctrlPosInfo">Drag buttons to position them</span>
</div>
<div style="position:relative;background:var(--bg-elevated);border-radius:8px;overflow:hidden;border:1px solid var(--border-dim);" id="ctrlPosViewport">
<div id="ctrlPosFrame" style="position:relative;margin:0 auto;background:#111;overflow:hidden;min-height:200px">
<!-- Control buttons will be rendered here -->
</div>
</div>
</div>
<div class="modal-foot">
<button class="topbar-btn" onclick="closeMod('modalCtrlPos')">Cancel</button>
<button class="topbar-btn primary" onclick="saveCtrlPositions()">Apply</button>
</div>
</div>
</div>
<div class="toast-box" id="toastBox"></div>
<script>
// ===== DATA =====
let project = { name:'Untitled Project', startNodeId:null, settings:{ showControls:true, autoHide:true, allowSeeking:true, showProgress:true, accentColor:'#4a9eff', playIcon:'', pauseIcon:'', endBehavior:'pause', videoFit:'contain', videoBasePath:'./videos/', transition:'fade', transitionDuration:600, choiceTitle:'What do you choose?', choiceSubtitle:'Select an option to continue', showChoiceOverlay:true, loadingText:'Loading...', loadingBg:'#000000', autoPlay:true } };
let library = []; // {libId, name, src, blobUrl, duration}
let nodes = []; // {nodeId, libId, name, x, y, duration, blobUrl, src}
let interactions = []; // {intId, nodeId, type, label, startTime, endTime, x, y, w, h, action:{type,value,pauseVideo}, style:{bg,color,icon,image}}
let connections = []; // {connId, fromPortId, toNodeId, label, color} — fromPortId = nodeId (video out) or intId (interaction out)
let selNode=null, selInt=null, selConn=null, editIntId=null, addIntNode=null;
let cOff={x:60,y:60}, cZoom=1;
let undoSt=[], redoSt=[];
let _id=1; const gid=()=>'_'+(_id++);
// ===== TOAST =====
function toast(m,t='ok'){const b=document.getElementById('toastBox'),d=document.createElement('div');d.className='toast-msg '+t;d.innerHTML=`<i class="bi bi-${t==='ok'?'check-circle':'exclamation-circle'}"></i> ${m}`;b.appendChild(d);setTimeout(()=>{d.style.opacity='0';d.style.transition='opacity .3s';setTimeout(()=>d.remove(),300)},2200);}
// ===== UNDO =====
function snap(){undoSt.push(JSON.stringify({project,library,nodes,interactions,connections}));if(undoSt.length>60)undoSt.shift();redoSt=[];}
function restore(s){const d=JSON.parse(s);project=d.project;library=d.library;nodes=d.nodes;interactions=d.interactions;connections=d.connections;}
function doUndo(){if(!undoSt.length)return;redoSt.push(JSON.stringify({project,library,nodes,interactions,connections}));restore(undoSt.pop());fullRender();toast('Undo');}
function doRedo(){if(!redoSt.length)return;undoSt.push(JSON.stringify({project,library,nodes,interactions,connections}));restore(redoSt.pop());fullRender();toast('Redo');}
// ===== TABS =====
function switchTab(el){el.parentElement.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));el.classList.add('active');document.querySelectorAll('[id^="tab-"]').forEach(c=>c.style.display='none');document.getElementById('tab-'+el.dataset.tab).style.display='flex';}
// ===== LIBRARY =====
function addToLib(files){
Array.from(files).forEach(f=>{
const libId=gid(), blobUrl=URL.createObjectURL(f);
const item={libId, name:f.name.replace(/\.[^.]+$/,''), filename:f.name, blobUrl, duration:0};
const v=document.createElement('video');v.src=blobUrl;
v.onloadedmetadata=()=>{item.duration=v.duration;renderLib();};
library.push(item);
});
renderLib(); toast(files.length+' video(s) added');
}
function getVideoPath(item){ return item.src || (project.settings.videoBasePath||'./videos/')+(item.filename||item.name+'.mp4'); }
function removeLib(id){library=library.filter(l=>l.libId!==id);renderLib();}
function renderLib(){
document.getElementById('libCount').textContent=library.length;
document.getElementById('libList').innerHTML=library.map(v=>{
const vidSrc=v.blobUrl||getVideoPath(v);
const safeSrc=vidSrc.replace(/"/g,'"');
return `
<div class="lib-item" data-libid="${v.libId}" onmousedown="startLibDrag(event,'${v.libId}')">
<div class="thumb" onmousedown="event.stopPropagation()" onclick="showLibPreview('${v.libId}')" style="cursor:zoom-in" title="Click for preview">
<video src="${safeSrc}" muted preload="metadata" playsinline></video>
</div>
<div class="info"><div class="name">${v.name}</div><div class="meta">${fmt(v.duration)} · ${getVideoPath(v)}</div></div>
<div class="actions">
<button onmousedown="event.stopPropagation()" onclick="showLibPreview('${v.libId}')" title="Preview"><i class="bi bi-info-circle"></i></button>
<button onmousedown="event.stopPropagation()" onclick="removeLib('${v.libId}')" title="Remove"><i class="bi bi-x"></i></button>
</div>
</div>`;
}).join('');
// Force first-frame load on all library thumbnails
document.querySelectorAll('.lib-item .thumb video').forEach(v=>{
v.addEventListener('loadedmetadata',function(){try{v.currentTime=0.1;}catch(e){}},{once:true});
});
}
function showLibPreview(libId){
const item=library.find(l=>l.libId===libId);if(!item)return;
const vidSrc=item.blobUrl||getVideoPath(item);
const path=getVideoPath(item);
// Count usages on canvas
const usedCount=nodes.filter(n=>n.libId===libId).length;
document.getElementById('libPrevTitle').textContent=item.name;
document.getElementById('libPrevBody').innerHTML=`
<div style="background:#000;border-radius:8px;overflow:hidden;margin-bottom:12px">
<video src="${vidSrc}" controls style="width:100%;display:block;max-height:400px;background:#000"></video>
</div>
<div style="display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;font-size:12px">
<div style="color:var(--text-dim)">Name:</div><div><strong>${item.name}</strong></div>
<div style="color:var(--text-dim)">Filename:</div><div style="font-family:var(--font-mono);font-size:11px;word-break:break-all">${item.filename||'—'}</div>
<div style="color:var(--text-dim)">Full Path:</div><div style="font-family:var(--font-mono);font-size:11px;word-break:break-all;color:var(--accent-blue)">${path}</div>
<div style="color:var(--text-dim)">Duration:</div><div>${fmt(item.duration)}</div>
<div style="color:var(--text-dim)">On Canvas:</div><div>${usedCount} node${usedCount!==1?'s':''}</div>
<div style="color:var(--text-dim)">Source:</div><div>${item.blobUrl?'Local upload (session only)':'Server path'}</div>
</div>`;
document.getElementById('modalLibPrev').classList.add('show');
}
function showNodeVideoPreview(nid){
const n=nodes.find(x=>x.nodeId===nid);if(!n)return;
const vidSrc=n.blobUrl||n.src||(project.settings.videoBasePath||'./videos/')+(n.filename||n.name+'.mp4');
const path=n.src||(project.settings.videoBasePath||'./videos/')+(n.filename||n.name+'.mp4');
const isStart=project.startNodeId===nid;
const intCount=interactions.filter(i=>i.nodeId===nid).length;
const connCount=connections.filter(c=>{const owner=portOwner(c.fromPortId);return owner===nid||c.toNodeId===nid;}).length;
document.getElementById('libPrevTitle').textContent=n.name+(isStart?' (START NODE)':'');
document.getElementById('libPrevBody').innerHTML=`
<div style="background:#000;border-radius:8px;overflow:hidden;margin-bottom:12px">
<video src="${vidSrc}" controls style="width:100%;display:block;max-height:400px;background:#000"></video>
</div>
<div style="display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;font-size:12px">
<div style="color:var(--text-dim)">Name:</div><div><strong>${n.name}</strong></div>
<div style="color:var(--text-dim)">Filename:</div><div style="font-family:var(--font-mono);font-size:11px;word-break:break-all">${n.filename||'—'}</div>
<div style="color:var(--text-dim)">Full Path:</div><div style="font-family:var(--font-mono);font-size:11px;word-break:break-all;color:var(--accent-blue)">${path}</div>
<div style="color:var(--text-dim)">Duration:</div><div>${fmt(n.duration)}</div>
<div style="color:var(--text-dim)">Interactions:</div><div>${intCount}</div>
<div style="color:var(--text-dim)">Connections:</div><div>${connCount}</div>
${isStart?'<div style="color:var(--text-dim)">Role:</div><div style="color:var(--accent-green);font-weight:600">Start Node</div>':''}
</div>`;
document.getElementById('modalLibPrev').classList.add('show');
}
// ===== DRAG FROM LIB TO CANVAS =====
let libDrag=null; const ghost=document.getElementById('dragGhost');
function startLibDrag(e,libId){e.preventDefault();const item=library.find(l=>l.libId===libId);if(!item)return;libDrag={libId,sx:e.clientX,sy:e.clientY,active:false,item};}
document.addEventListener('mousemove',e=>{
if(!libDrag)return;
if(!libDrag.active&&(Math.abs(e.clientX-libDrag.sx)+Math.abs(e.clientY-libDrag.sy))>6){libDrag.active=true;ghost.textContent=libDrag.item.name;ghost.style.display='block';}
if(libDrag.active){
ghost.style.left=(e.clientX+12)+'px';ghost.style.top=(e.clientY-14)+'px';
const cr=document.getElementById('canvas').getBoundingClientRect();
document.getElementById('dropInd').style.display=(e.clientX>=cr.left&&e.clientX<=cr.right&&e.clientY>=cr.top&&e.clientY<=cr.bottom)?'block':'none';
}
});
document.addEventListener('mouseup',e=>{
if(!libDrag)return;
ghost.style.display='none';document.getElementById('dropInd').style.display='none';
if(libDrag.active){
const cr=document.getElementById('canvas').getBoundingClientRect();
if(e.clientX>=cr.left&&e.clientX<=cr.right&&e.clientY>=cr.top&&e.clientY<=cr.bottom){
snap();const lib=libDrag.item;
const node={nodeId:gid(),libId:lib.libId,name:lib.name,filename:lib.filename,x:(e.clientX-cr.left-cOff.x)/cZoom,y:(e.clientY-cr.top-cOff.y)/cZoom,duration:lib.duration,blobUrl:lib.blobUrl||'',src:getVideoPath(lib),startTransition:'',endTransition:''};
nodes.push(node);
if(!project.startNodeId)project.startNodeId=node.nodeId;
fullRender();selectNode(node.nodeId);toast('Node created');
}
}
libDrag=null;
});
// File drop on add zone
const az=document.getElementById('addZone');
az.addEventListener('dragover',e=>{e.preventDefault();az.style.borderColor='var(--accent-blue)';az.style.background='var(--accent-blue-dim)';});
az.addEventListener('dragleave',()=>{az.style.borderColor='';az.style.background='';});
az.addEventListener('drop',e=>{e.preventDefault();az.style.borderColor='';az.style.background='';const f=Array.from(e.dataTransfer.files).filter(f=>f.type.startsWith('video/'));if(f.length)addToLib(f);});
// ===== RENDER NODES =====
function updateNodePositions(){
// Fast path: just update position/scale without rebuilding DOM (prevents video reload on zoom/pan)
nodes.forEach(n=>{
const el=document.getElementById('nd-'+n.nodeId);
if(!el)return;
el.style.left=(n.x*cZoom+cOff.x)+'px';
el.style.top=(n.y*cZoom+cOff.y)+'px';
el.style.transform=`scale(${cZoom})`;
});
}
function renderNodes(){
const layer=document.getElementById('nodeLayer');
// Detach and preserve existing video elements (prevents reload thrashing)
const existingVideos={};
layer.querySelectorAll('.vnode').forEach(n=>{
const nid=n.id.replace('nd-','');
const vid=n.querySelector('.vnode-preview video');
if(vid){vid.remove();existingVideos[nid]=vid;}
n.remove();
});
document.getElementById('canvasEmpty').style.display=nodes.length?'none':'flex';
nodes.forEach(n=>{
const el=document.createElement('div');
const isStart=project.startNodeId===n.nodeId;
el.className='vnode'+(selNode===n.nodeId?' selected':'')+(isStart?' start-node':'');
el.id='nd-'+n.nodeId;
el.style.left=(n.x*cZoom+cOff.x)+'px';
el.style.top=(n.y*cZoom+cOff.y)+'px';
el.style.transform=`scale(${cZoom})`;
el.style.transformOrigin='top left';
const vidOut=connections.find(c=>c.fromPortId===n.nodeId);
const ints=interactions.filter(i=>i.nodeId===n.nodeId);
const nVidSrc=n.blobUrl||n.src||(project.settings.videoBasePath||'./videos/')+(n.filename||n.name+'.mp4');
const safeNSrc=nVidSrc.replace(/"/g,'"');
const cachedVid=existingVideos[n.nodeId];
const vidHtml=cachedVid?`<div class="vnode-preview" data-vid-placeholder="1"></div>`:`<div class="vnode-preview"><video src="${safeNSrc}" muted preload="metadata" playsinline></video></div>`;
el.innerHTML=`
${vidHtml}
<div class="vnode-header" data-nid="${n.nodeId}">
<div class="vnode-badge">${isStart?'START':'VIDEO'}</div>
<div class="vnode-name">${n.name}</div>
<div class="vnode-dur">${fmt(n.duration)}</div>
</div>
<div class="vnode-ports-row">
<div class="port port-in" data-pt="in" data-nid="${n.nodeId}" title="IN (accepts many)"></div>
<div class="port-row-center">video flow</div>
<div class="port port-out ${vidOut?'connected':''}" data-pt="out" data-pid="${n.nodeId}" title="OUT (single)"></div>
</div>
${ints.map(i=>{
const ic=connections.find(c=>c.fromPortId===i.intId);
return `<div class="vnode-int-item ${selInt===i.intId?'selected':''}" data-iid="${i.intId}">
<div class="int-icon ${i.type}"><i class="bi bi-${intIco(i.type)}"></i></div>
<div class="vnode-int-label">${i.label||i.type}</div>
<div class="vnode-int-time">${fmt(i.startTime)}-${fmt(i.endTime)}</div>
<div class="port port-out ${ic?'connected':''}" data-pt="out" data-pid="${i.intId}" title="OUT (single)"></div>
<button class="del-int" title="Delete"><i class="bi bi-x"></i></button>
</div>`;
}).join('')}
<div class="vnode-add-int"><i class="bi bi-plus"></i> add interaction</div>
`;
// Re-insert cached video element to prevent reload
if(cachedVid){
const holder=el.querySelector('.vnode-preview[data-vid-placeholder]');
if(holder){holder.removeAttribute('data-vid-placeholder');holder.appendChild(cachedVid);}
}
// Header drag
el.querySelector('.vnode-header').addEventListener('mousedown',e=>{if(e.button!==0)return;e.preventDefault();e.stopPropagation();selectNode(n.nodeId);ndDrag={nid:n.nodeId,sx:e.clientX,sy:e.clientY,ox:n.x,oy:n.y};});
// Preview area drag
el.querySelector('.vnode-preview').addEventListener('mousedown',e=>{if(e.button!==0)return;e.preventDefault();e.stopPropagation();selectNode(n.nodeId);ndDrag={nid:n.nodeId,sx:e.clientX,sy:e.clientY,ox:n.x,oy:n.y};});
// Right-click
el.addEventListener('contextmenu',e=>{e.preventDefault();selectNode(n.nodeId);showCtx(e.clientX,e.clientY,n.nodeId);});
// Click body to select
el.addEventListener('mousedown',e=>{
if(e.target.closest('.port')||e.target.closest('.del-int')||e.target.closest('.vnode-add-int')||e.target.closest('.vnode-int-item'))return;
if(e.button===0){e.stopPropagation();selectNode(n.nodeId);}
});
// Interaction items
el.querySelectorAll('.vnode-int-item').forEach(item=>{
item.addEventListener('mousedown',e=>{
if(e.target.closest('.port')||e.target.closest('.del-int'))return;
e.stopPropagation();selectInteraction(item.dataset.iid);
});
});
el.querySelectorAll('.del-int').forEach(btn=>{
btn.addEventListener('click',e=>{e.stopPropagation();const item=btn.closest('.vnode-int-item');if(item)removeInt(item.dataset.iid);});
});
// Add interaction button
el.querySelector('.vnode-add-int').addEventListener('click',e=>{e.stopPropagation();openAddInt(n.nodeId);});
// Port events
el.querySelectorAll('.port').forEach(port=>{
port.addEventListener('mousedown',e=>{
e.preventDefault();e.stopPropagation();
if(port.dataset.pt==='out'){
const pid=port.dataset.pid;
if(connections.find(c=>c.fromPortId===pid)){toast('Already connected — delete existing first','err');return;}
startConn(pid,port);
}
});
});
layer.appendChild(el);
});
// Force first-frame preview on fresh videos (so thumbnails actually show)
layer.querySelectorAll('.vnode-preview video').forEach(v=>{
if(v.dataset.seeked)return;
v.addEventListener('loadedmetadata',function(){try{v.currentTime=0.1;v.dataset.seeked='1';}catch(e){}},{once:true});
});
}
// ===== NODE DRAG =====
let ndDrag=null;
document.addEventListener('mousemove',e=>{
if(ndDrag){const n=nodes.find(x=>x.nodeId===ndDrag.nid);if(n){n.x=ndDrag.ox+(e.clientX-ndDrag.sx)/cZoom;n.y=ndDrag.oy+(e.clientY-ndDrag.sy)/cZoom;updateNodePositions();renderConns();}}
});
document.addEventListener('mouseup',()=>{ndDrag=null;});
// ===== CONNECTION DRAG =====
let connDrag=null, tempPath=null;
function startConn(fromPid,portEl){const r=portEl.getBoundingClientRect();connDrag={fromPid,sx:r.left+r.width/2,sy:r.top+r.height/2};}
document.addEventListener('mousemove',e=>{
if(!connDrag)return;
const svg=document.getElementById('connSvg');
if(!tempPath){tempPath=document.createElementNS('http://www.w3.org/2000/svg','path');tempPath.setAttribute('stroke','#3ddc84');tempPath.setAttribute('stroke-width','2');tempPath.setAttribute('stroke-dasharray','6,4');tempPath.setAttribute('fill','none');tempPath.setAttribute('opacity','0.8');svg.appendChild(tempPath);}
const cr=document.getElementById('canvas').getBoundingClientRect();
const x1=connDrag.sx-cr.left,y1=connDrag.sy-cr.top,x2=e.clientX-cr.left,y2=e.clientY-cr.top,dx=Math.max(Math.abs(x2-x1)*0.4,40);
tempPath.setAttribute('d',`M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`);
});
document.addEventListener('mouseup',e=>{
if(!connDrag)return;
if(tempPath){tempPath.remove();tempPath=null;}
const tgt=document.elementFromPoint(e.clientX,e.clientY);
if(tgt&&tgt.classList.contains('port')&&tgt.dataset.pt==='in'){
const toNid=tgt.dataset.nid;
const fromOwner=portOwner(connDrag.fromPid);
if(fromOwner&&fromOwner!==toNid){
snap();
// Auto-label from interaction if applicable
const fromInt=interactions.find(i=>i.intId===connDrag.fromPid);
const autoLabel=fromInt?(fromInt.label||fromInt.type):'';
connections.push({connId:gid(),fromPortId:connDrag.fromPid,toNodeId:toNid,label:autoLabel,color:'#4a9eff',transition:''});
fullRender();toast('Connected');
}
}
connDrag=null;
});
function portOwner(pid){const n=nodes.find(x=>x.nodeId===pid);if(n)return n.nodeId;const i=interactions.find(x=>x.intId===pid);if(i)return i.nodeId;return null;}
// ===== RENDER CONNECTIONS =====
function renderConns(){
const svg=document.getElementById('connSvg');
svg.querySelectorAll('path:not([stroke-dasharray])').forEach(p=>p.remove());
svg.querySelectorAll('path.hit-area').forEach(p=>p.remove());
document.querySelectorAll('.conn-label-el').forEach(l=>l.remove());
const cr=document.getElementById('canvas').getBoundingClientRect();
connections.forEach(conn=>{
const fromEl=document.querySelector(`.port[data-pid="${conn.fromPortId}"]`);
const toEl=document.querySelector(`#nd-${conn.toNodeId} .port[data-pt="in"]`);
if(!fromEl||!toEl)return;
const fp=fromEl.getBoundingClientRect(),tp=toEl.getBoundingClientRect();
const x1=fp.left+fp.width/2-cr.left,y1=fp.top+fp.height/2-cr.top;
const x2=tp.left+tp.width/2-cr.left,y2=tp.top+tp.height/2-cr.top;
const dx=Math.max(Math.abs(x2-x1)*0.4,40);
const pathD=`M${x1},${y1} C${x1+dx},${y1} ${x2-dx},${y2} ${x2},${y2}`;
// Invisible wide hit area for easy clicking
const hitPath=document.createElementNS('http://www.w3.org/2000/svg','path');
hitPath.setAttribute('d',pathD);
hitPath.setAttribute('class','hit-area');
hitPath.addEventListener('click',e=>{e.stopPropagation();selectConn(conn.connId);});
svg.appendChild(hitPath);
// Visible path
const path=document.createElementNS('http://www.w3.org/2000/svg','path');
path.setAttribute('d',pathD);
path.setAttribute('stroke',conn.color||'#4a9eff');
path.setAttribute('stroke-width',selConn===conn.connId?'4':'2.5');
path.setAttribute('opacity',selConn===conn.connId?'1':'0.6');
path.style.cursor='pointer';path.style.pointerEvents='stroke';
path.addEventListener('click',e=>{e.stopPropagation();selectConn(conn.connId);});
svg.appendChild(path);
if(conn.label){
const lbl=document.createElement('div');lbl.className='conn-label-el';
lbl.style.cssText=`position:absolute;left:${(x1+x2)/2}px;top:${(y1+y2)/2-10}px;background:var(--bg-card);border:1px solid var(--border-dim);border-radius:4px;padding:1px 6px;font-size:9px;color:var(--text-secondary);z-index:16;cursor:pointer;white-space:nowrap;`;
lbl.textContent=conn.label;lbl.onclick=()=>selectConn(conn.connId);
document.getElementById('canvas').appendChild(lbl);
}
});
}
// ===== SELECTION =====
function selectNode(nid){selNode=nid;selInt=null;selConn=null;renderNodes();renderConns();showNodeProps(nid);showTimeline(nid);}
function selectInteraction(iid){const i=interactions.find(x=>x.intId===iid);if(!i)return;selInt=iid;selConn=null;selNode=i.nodeId;renderNodes();renderConns();showIntProps(iid);showTimeline(i.nodeId);}
function selectConn(cid){selConn=cid;selInt=null;renderNodes();renderConns();showConnProps(cid);}
function clearSel(){selNode=null;selInt=null;selConn=null;document.getElementById('propsArea').style.display='none';document.getElementById('propsEmpty').style.display='flex';document.getElementById('btnAddInt').style.display='none';document.getElementById('tlTitle').textContent='Timeline — select a node';document.getElementById('tlEmpty').style.display='flex';document.getElementById('tlContent').style.display='none';renderNodes();renderConns();}
// ===== PROPS: NODE =====
function showNodeProps(nid){
const n=nodes.find(x=>x.nodeId===nid);if(!n)return;
document.getElementById('propsEmpty').style.display='none';
document.getElementById('propsArea').style.display='block';
document.getElementById('propsTitle').textContent='Video Node';
const isStart = project.startNodeId===nid;
const hasOut = connections.some(c=>c.fromPortId===nid);
const hasIntOuts = interactions.filter(i=>i.nodeId===nid).some(i=>connections.some(c=>c.fromPortId===i.intId));
const mkSel=(opts,val)=>opts.map(o=>`<option value="${o[0]}" ${o[0]===val?'selected':''}>${o[1]}</option>`).join('');
const startTrOpts=[['','None (instant)'],['fadeInBlack','Fade in from black'],['fadeInWhite','Fade in from white'],['blur','Blur in'],['zoomIn','Zoom in']];
const endTrOpts=[['','Default (global)'],['cut','Cut (instant)'],['fade','Fade to black'],['fadeWhite','Fade to white'],['blur','Blur out'],['zoomIn','Zoom out']];
const endBehOpts=[['','Use global setting'],['pause','Pause on last frame'],['replay','Replay this video'],['playthrough','Auto-continue (if connected)'],['choices','Show choices (if interactions connected)']];
const nodeVidSrc=n.blobUrl||n.src||(project.settings.videoBasePath||'./videos/')+(n.filename||n.name+'.mp4');
document.getElementById('propsContent').innerHTML=`
<div class="prop-group">
<div class="prop-group-title">Preview</div>
<div style="background:#000;border-radius:6px;overflow:hidden;position:relative;aspect-ratio:16/9;cursor:zoom-in" onclick="showNodeVideoPreview('${nid}')" title="Click to enlarge">
<video src="${nodeVidSrc}#t=0.1" muted preload="metadata" style="width:100%;height:100%;object-fit:cover;display:block" onerror="this.outerHTML='<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.5);background:#222"><i class="bi bi-exclamation-triangle" style="font-size:24px"></i></div>'"></video>
<div style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,0.6);color:#fff;border-radius:4px;padding:2px 6px;font-size:10px;display:flex;align-items:center;gap:4px"><i class="bi bi-zoom-in"></i> Click to preview</div>
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Node</div>
<div class="prop-field"><label>Name</label><input value="${n.name}" onchange="snap();nodes.find(x=>x.nodeId==='${nid}').name=this.value;fullRender();"></div>
<div class="prop-field"><label>Video Path</label><input value="${n.src||(project.settings.videoBasePath||'./videos/')+(n.filename||n.name+'.mp4')}" onchange="snap();nodes.find(x=>x.nodeId==='${nid}').src=this.value;fullRender();" placeholder="./videos/myvideo.mp4">
<div style="font-size:9px;color:var(--text-dim);margin-top:2px">Override the auto-generated path if needed</div>
</div>
<div class="prop-field"><label>Duration</label><input value="${fmt(n.duration)}" readonly style="opacity:.5"></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" ${isStart?'checked':''} onchange="snap();project.startNodeId=this.checked?'${nid}':null;fullRender();selectNode('${nid}');"> Start node</label></div>
</div>
<div class="prop-group">
<div class="prop-group-title">End of Video Behavior</div>
<div class="prop-field"><label>When this video ends</label><select onchange="snap();nodes.find(x=>x.nodeId==='${nid}').endBehavior=this.value;">
${mkSel(endBehOpts, n.endBehavior||'')}
</select></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Audio Override</div>
<div class="prop-field"><label>Audio for this node</label><select onchange="snap();nodes.find(x=>x.nodeId==='${nid}').audioMode=this.value;">
${mkSel([['','Follow global setting'],['video','Play video audio'],['bgm','Play background music'],['custom','Play custom audio'],['mute','Mute']],n.audioMode||'')}
</select></div>
<div class="prop-field"><label>Custom Audio Path</label><input value="${n.audioFile||''}" onchange="snap();nodes.find(x=>x.nodeId==='${nid}').audioFile=this.value;" placeholder="./audio/scene1.mp3"></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" ${n.audioLoop!==false?'checked':''} onchange="snap();nodes.find(x=>x.nodeId==='${nid}').audioLoop=this.checked;"> Loop custom audio</label></div>
</div>
<div class="prop-group">
<div class="prop-group-title">Overlay Image Override</div>
<div style="font-size:10px;color:var(--text-dim);margin-bottom:6px">Override the global overlay image for this video node</div>
<div class="prop-field"><label>Overlay Image</label><div style="display:flex;gap:4px;align-items:center">
<button class="prop-btn" style="width:auto;padding:3px 8px;font-size:10px" onclick="document.getElementById('nodeOvFile').click()"><i class="bi bi-upload"></i></button>
<span id="nodeOvPreview" style="font-size:10px;color:var(--text-dim)">${n.overlayImg?'Set':'None (use global)'}</span>
<button class="prop-btn" style="width:auto;padding:3px 6px;font-size:10px" onclick="snap();nodes.find(x=>x.nodeId==='${nid}').overlayImg='';selectNode('${nid}')"><i class="bi bi-x"></i></button>
</div><input type="file" id="nodeOvFile" accept="image/*" hidden onchange="handleNodeOvImg(this,'${nid}')"></div>
${n.overlayImg?`<div style="margin-top:4px"><img src="${n.overlayImg}" style="max-width:120px;border-radius:4px;border:1px solid var(--border-dim)"></div>`:''}
</div>
${isStart?`<div class="prop-group">
<div class="prop-group-title">Start Transition (intro)</div>
<div class="prop-field"><label>Transition In</label><select onchange="snap();nodes.find(x=>x.nodeId==='${nid}').startTransition=this.value;">
${mkSel(startTrOpts, n.startTransition||'')}
</select></div>
</div>`:''}
${!hasOut?`<div class="prop-group">
<div class="prop-group-title">End Transition (outro)</div>
<div class="prop-field"><label>Transition Out</label><select onchange="snap();nodes.find(x=>x.nodeId==='${nid}').endTransition=this.value;">
${mkSel(endTrOpts, n.endTransition||'')}
</select></div>
</div>`:''}
<div class="prop-group">
<div class="prop-group-title">Interactions (${interactions.filter(i=>i.nodeId===nid).length})</div>
<button class="prop-btn" onclick="openAddInt('${nid}')"><i class="bi bi-plus"></i> Add Interaction</button>
</div>
<div class="prop-group" style="margin-top:16px"><button class="prop-btn danger" onclick="deleteNode('${nid}')"><i class="bi bi-trash3"></i> Delete Node</button></div>
`;
}
// ===== PROPS: INTERACTION =====
function showIntProps(iid){
const i=interactions.find(x=>x.intId===iid);if(!i)return;
document.getElementById('propsEmpty').style.display='none';
document.getElementById('propsArea').style.display='block';
document.getElementById('propsTitle').textContent='Element';
const ct=connections.find(c=>c.fromPortId===iid);
const tgtName=ct?(nodes.find(n=>n.nodeId===ct.toNodeId)?.name||'?'):'Not connected';
const st=i.style||{};
const hasImg=st.image&&st.image.length>10;
const isClickable=i.clickable!==false;
const isText=i.type==='text';
const isShape=i.type==='shape';
const isImage=i.type==='image';
document.getElementById('propsContent').innerHTML=`
<div class="prop-group">
<div class="prop-group-title">Element Type</div>
<div class="prop-field"><label>Type</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').type=this.value;fullRender();selectInteraction('${iid}');">
<optgroup label="Display Elements">
<option value="text" ${i.type==='text'?'selected':''}>Text</option>
<option value="shape" ${i.type==='shape'?'selected':''}>Shape</option>
<option value="image" ${i.type==='image'?'selected':''}>Image</option>
</optgroup>
<optgroup label="Interactive Elements">
<option value="button" ${i.type==='button'?'selected':''}>Button</option>
<option value="hotspot" ${i.type==='hotspot'?'selected':''}>Hotspot</option>
<option value="choice" ${i.type==='choice'?'selected':''}>Choice</option>
<option value="overlay" ${i.type==='overlay'?'selected':''}>Overlay Popup</option>
<option value="timer" ${i.type==='timer'?'selected':''}>Timer</option>
</optgroup>
</select></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" ${isClickable?'checked':''} onchange="snap();interactions.find(x=>x.intId==='${iid}').clickable=this.checked;fullRender();"> Clickable (interaction trigger)</label>
<div style="font-size:10px;color:var(--text-dim);margin-top:2px">Uncheck for purely decorative elements that don't respond to clicks</div>
</div>
</div>
${isText?`
<div class="prop-group">
<div class="prop-group-title">Text Content</div>
<div class="prop-field"><label>Text</label><textarea rows="3" onchange="snap();interactions.find(x=>x.intId==='${iid}').label=this.value;fullRender();" style="font-family:var(--font-sans);resize:vertical">${i.label||''}</textarea></div>
<div class="prop-field"><label>Font Family</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.fontFamily=this.value;fullRender();">
${[
['','Default (DM Sans)'],
['Arial, sans-serif','Arial'],
['Helvetica, Arial, sans-serif','Helvetica'],
['"Times New Roman", Times, serif','Times New Roman'],
['Georgia, serif','Georgia'],
['"Courier New", Courier, monospace','Courier New'],
['"Trebuchet MS", sans-serif','Trebuchet MS'],
['Verdana, Geneva, sans-serif','Verdana'],
['Tahoma, Geneva, sans-serif','Tahoma'],
['Impact, Charcoal, sans-serif','Impact'],
['"Comic Sans MS", cursive','Comic Sans MS'],
['"Lucida Console", Monaco, monospace','Lucida Console'],
['"Brush Script MT", cursive','Brush Script'],
['"Inter", sans-serif','Inter (Google)'],
['"Poppins", sans-serif','Poppins (Google)'],
['"Montserrat", sans-serif','Montserrat (Google)'],
['"Playfair Display", serif','Playfair Display (Google)'],
['"Roboto", sans-serif','Roboto (Google)'],
['"Lora", serif','Lora (Google)'],
['"Pacifico", cursive','Pacifico (Google)'],
['"Bebas Neue", sans-serif','Bebas Neue (Google)']
].map(o=>`<option value="${o[0]}" ${(st.fontFamily||'')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select>
<div style="font-size:9px;color:var(--text-dim);margin-top:2px">Google Fonts are auto-loaded. Or type custom family in the custom CSS field.</div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Font Size (px)</label><input type="number" min="6" max="400" value="${st.fontSize||16}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.fontSize=+this.value;fullRender();"></div>
<div class="prop-field" style="flex:1"><label>Font Weight</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.fontWeight=this.value;fullRender();">
${[['100','Thin'],['300','Light'],['400','Normal'],['500','Medium'],['600','Semibold'],['700','Bold'],['800','Extra Bold'],['900','Black']].map(o=>`<option value="${o[0]}" ${(st.fontWeight||'600')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Horizontal Align</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.textAlign=this.value;fullRender();">
${[['left','Left'],['center','Center'],['right','Right'],['justify','Justify']].map(o=>`<option value="${o[0]}" ${(st.textAlign||'center')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
<div class="prop-field" style="flex:1"><label>Vertical Align</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.verticalAlign=this.value;fullRender();">
${[['center','Middle'],['flex-start','Top'],['flex-end','Bottom']].map(o=>`<option value="${o[0]}" ${(st.verticalAlign||'center')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Style</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.fontStyle=this.value;fullRender();">
${[['normal','Normal'],['italic','Italic']].map(o=>`<option value="${o[0]}" ${(st.fontStyle||'normal')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
<div class="prop-field" style="flex:1"><label>Decoration</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.textDecoration=this.value;fullRender();">
${[['none','None'],['underline','Underline'],['line-through','Strikethrough'],['overline','Overline']].map(o=>`<option value="${o[0]}" ${(st.textDecoration||'none')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Line Height</label><input type="number" step="0.1" min="0.8" max="3" value="${st.lineHeight||1.3}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.lineHeight=+this.value;fullRender();"></div>
<div class="prop-field" style="flex:1"><label>Letter Spacing (px)</label><input type="number" step="0.5" min="-5" max="20" value="${st.letterSpacing||0}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.letterSpacing=+this.value;fullRender();"></div>
</div>
<div class="prop-field"><label>Text Transform</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.textTransform=this.value;fullRender();">
${[['none','None'],['uppercase','UPPERCASE'],['lowercase','lowercase'],['capitalize','Capitalize']].map(o=>`<option value="${o[0]}" ${(st.textTransform||'none')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
<div class="prop-field"><label>Text Shadow</label><input value="${st.textShadow||''}" placeholder="2px 2px 4px rgba(0,0,0,0.5)" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.textShadow=this.value;fullRender();"></div>
</div>`:''}
<div class="prop-group">
<div class="prop-group-title">Transparency</div>
<div class="prop-field">
<label>Opacity: <span id="op_val_${iid}" style="color:var(--accent-blue);font-weight:600">${Math.round((st.opacity!==undefined?st.opacity:1)*100)}%</span></label>
<input type="range" min="0" max="100" step="1" value="${Math.round((st.opacity!==undefined?st.opacity:1)*100)}" oninput="document.getElementById('op_val_${iid}').textContent=this.value+'%';interactions.find(x=>x.intId==='${iid}').style.opacity=+this.value/100;" onchange="snap();" style="width:100%">
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Custom CSS (advanced)</div>
<div style="font-size:10px;color:var(--text-dim);margin-bottom:6px">Add any CSS properties. Overrides other settings. Example: <code>background:linear-gradient(45deg,#f00,#00f);backdrop-filter:blur(10px);</code></div>
<div class="prop-field"><textarea rows="4" style="font-family:var(--font-mono);font-size:11px;resize:vertical" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.customCss=this.value;fullRender();" placeholder="property: value; property: value;">${st.customCss||''}</textarea></div>
</div>
${isShape?`
<div class="prop-group">
<div class="prop-group-title">Shape</div>
<div class="prop-field"><label>Shape Type</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.shapeType=this.value;fullRender();">
${[['rectangle','Rectangle'],['rounded','Rounded Rectangle'],['circle','Circle / Ellipse'],['triangle','Triangle'],['line','Line']].map(o=>`<option value="${o[0]}" ${(st.shapeType||'rectangle')===o[0]?'selected':''}>${o[1]}</option>`).join('')}
</select></div>
<div class="prop-field"><label>Label (optional)</label><input value="${i.label||''}" onchange="snap();interactions.find(x=>x.intId==='${iid}').label=this.value;fullRender();"></div>
</div>`:''}
${!isText&&!isShape?`
<div class="prop-group">
<div class="prop-group-title">Label</div>
<div class="prop-field"><input value="${i.label||''}" onchange="snap();interactions.find(x=>x.intId==='${iid}').label=this.value;fullRender();selectInteraction('${iid}');" placeholder="Button text or hotspot label"></div>
</div>`:''}
<div class="prop-group">
<div class="prop-group-title">Timing (fade in/out)</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Start (s)</label><input type="number" step="0.1" min="0" value="${i.startTime}" onchange="snap();interactions.find(x=>x.intId==='${iid}').startTime=+this.value;fullRender();selectInteraction('${iid}');"></div>
<div class="prop-field" style="flex:1"><label>End (s)</label><input type="number" step="0.1" min="0" value="${i.endTime}" onchange="snap();interactions.find(x=>x.intId==='${iid}').endTime=+this.value;fullRender();selectInteraction('${iid}');"></div>
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Position</div>
<button class="prop-btn" style="margin-bottom:8px;background:var(--accent-blue-dim);border-color:rgba(74,158,255,0.3);color:var(--accent-blue)" onclick="openPosEditor('${iid}')"><i class="bi bi-bounding-box"></i> Visual Position Editor</button>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>X %</label><input type="number" min="0" max="100" value="${i.x}" onchange="snap();interactions.find(x=>x.intId==='${iid}').x=+this.value;"></div>
<div class="prop-field" style="flex:1"><label>Y %</label><input type="number" min="0" max="100" value="${i.y}" onchange="snap();interactions.find(x=>x.intId==='${iid}').y=+this.value;"></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Width %</label><input type="number" min="1" max="100" value="${i.w}" onchange="snap();interactions.find(x=>x.intId==='${iid}').w=+this.value;"></div>
<div class="prop-field" style="flex:1"><label>Height %</label><input type="number" min="1" max="100" value="${i.h}" onchange="snap();interactions.find(x=>x.intId==='${iid}').h=+this.value;"></div>
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Connection</div>
<div class="prop-field"><label>Connected to</label><input value="${tgtName}" readonly style="opacity:.5"></div>
${ct?`<button class="prop-btn danger" style="margin-top:4px" onclick="delConn('${ct.connId}')"><i class="bi bi-x"></i> Remove connection</button>`:'<div style="font-size:10px;color:var(--text-dim)">Drag OUT port → another node IN</div>'}
</div>
<div class="prop-group">
<div class="prop-group-title">Image</div>
<div class="prop-field">
<div style="display:flex;gap:6px;align-items:center;margin-bottom:6px">
<button class="prop-btn" style="width:auto;padding:4px 10px" onclick="document.getElementById('intImgUp').click()"><i class="bi bi-image"></i> Upload Image</button>
${hasImg?`<button class="prop-btn" style="width:auto;padding:4px 8px" onclick="snap();interactions.find(x=>x.intId==='${iid}').style.image='';selectInteraction('${iid}');"><i class="bi bi-x"></i> Remove</button>`:''}
</div>
<input type="file" id="intImgUp" accept="image/*" hidden onchange="handleIntImgProp(this,'${iid}')">
${hasImg?`<div style="margin-top:4px"><img src="${st.image}" style="max-width:120px;max-height:60px;border-radius:4px;border:1px solid var(--border-dim)"></div>`:'<div style="font-size:10px;color:var(--text-dim)">PNG, GIF, APNG, WebP</div>'}
</div>
</div>
<div class="prop-group">
<div class="prop-group-title">Style</div>
<div class="prop-field"><label>Background</label>
<div style="display:flex;gap:6px;align-items:center">
<input type="color" value="${st.bg||'#4a9eff'}" style="width:40px;flex-shrink:0" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.bg=this.value;">
<label class="prop-check" style="font-size:11px"><input type="checkbox" ${st.transparent?'checked':''} onchange="snap();interactions.find(x=>x.intId==='${iid}').style.transparent=this.checked;"> Transparent</label>
</div>
</div>
<div class="prop-field"><label>Text Color</label><input type="color" value="${st.color||'#ffffff'}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.color=this.value;"></div>
<div class="prop-field"><label>Border</label>
<div style="display:flex;gap:6px;align-items:center">
<input type="number" min="0" max="10" value="${st.borderWidth||0}" style="width:50px" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.borderWidth=+this.value;"> px
<input type="color" value="${st.borderColor||'#ffffff'}" style="width:40px" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.borderColor=this.value;">
</div>
</div>
<div class="prop-field"><label>Border Radius (px)</label><input type="number" min="0" max="100" value="${st.borderRadius||8}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.borderRadius=+this.value;"></div>
<div class="prop-field"><label>Shadow</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.shadow=this.value;">
<option value="" ${!st.shadow?'selected':''}>None</option>
<option value="sm" ${st.shadow==='sm'?'selected':''}>Small</option>
<option value="md" ${st.shadow==='md'?'selected':''}>Medium</option>
<option value="lg" ${st.shadow==='lg'?'selected':''}>Large</option>
<option value="glow" ${st.shadow==='glow'?'selected':''}>Glow</option>
</select></div>
<div class="prop-field"><label>Entrance Animation (<a href="https://animate.style" target="_blank" style="color:var(--accent-blue);font-size:10px">animate.css</a>)</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.animateIn=this.value;">
${mkAnimSel(st.animateIn||'')}
</select></div>
<div class="prop-field"><label>Exit Animation</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.animateOut=this.value;">
${mkAnimSel(st.animateOut||'',true)}
</select></div>
<div class="prop-field"><label>Attention Animation (loops)</label><select onchange="snap();interactions.find(x=>x.intId==='${iid}').style.animateAttention=this.value;">
${mkAttentionSel(st.animateAttention||'')}
</select></div>
<div class="prop-field"><label>Icon (bi-xxx)</label><input value="${st.icon||''}" onchange="snap();interactions.find(x=>x.intId==='${iid}').style.icon=this.value;fullRender();selectInteraction('${iid}');"></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" ${i.action?.pauseVideo!==false?'checked':''} onchange="snap();interactions.find(x=>x.intId==='${iid}').action.pauseVideo=this.checked;"> Pause video on trigger</label></div>
</div>
<div class="prop-group"><button class="prop-btn danger" onclick="removeInt('${iid}')"><i class="bi bi-trash3"></i> Delete Interaction</button></div>
`;
}
function handleIntImgProp(input,iid){
if(!input.files||!input.files[0])return;
const r=new FileReader();
r.onload=e=>{snap();const intr=interactions.find(i=>i.intId===iid);if(intr){if(!intr.style)intr.style={};intr.style.image=e.target.result;}selectInteraction(iid);};
r.readAsDataURL(input.files[0]);
}
function mkAnimSel(val,isExit){
const ins=['','bounceIn','bounceInDown','bounceInLeft','bounceInRight','bounceInUp','fadeIn','fadeInDown','fadeInLeft','fadeInRight','fadeInUp','flipInX','flipInY','lightSpeedInRight','rotateIn','slideInDown','slideInLeft','slideInRight','slideInUp','zoomIn','jackInTheBox','rollIn','heartBeat','pulse','rubberBand','shakeX','shakeY','swing','tada','wobble','jello','flash','bounce'];
const outs=['','bounceOut','bounceOutDown','bounceOutLeft','bounceOutRight','bounceOutUp','fadeOut','fadeOutDown','fadeOutLeft','fadeOutRight','fadeOutUp','flipOutX','flipOutY','lightSpeedOutRight','rotateOut','slideOutDown','slideOutLeft','slideOutRight','slideOutUp','zoomOut','rollOut','hinge'];
const list=isExit?outs:ins;
return list.map(a=>`<option value="${a}" ${a===val?'selected':''}>${a||'None'}</option>`).join('');
}
function mkAttentionSel(val){
const attn=['','bounce','flash','pulse','rubberBand','shakeX','shakeY','headShake','swing','tada','wobble','jello','heartBeat'];
return attn.map(a=>`<option value="${a}" ${a===val?'selected':''}>${a||'None'}</option>`).join('');
}
// ===== PROPS: CONNECTION =====
function showConnProps(cid){
const c=connections.find(x=>x.connId===cid);if(!c)return;
document.getElementById('propsEmpty').style.display='none';
document.getElementById('propsArea').style.display='block';
document.getElementById('propsTitle').textContent='Connection';
const fromOwner=portOwner(c.fromPortId);
const fromNode=nodes.find(n=>n.nodeId===fromOwner);
const fromInt=interactions.find(i=>i.intId===c.fromPortId);
const fromLabel=fromInt?`${fromNode?.name||'?'} → ${fromInt.label||fromInt.type}`:fromNode?.name||'?';
const toName=nodes.find(n=>n.nodeId===c.toNodeId)?.name||'?';
document.getElementById('propsContent').innerHTML=`
<div class="prop-group">
<div class="prop-group-title">Connection</div>
<div class="prop-field"><label>From</label><input value="${fromLabel}" readonly style="opacity:.5"></div>
<div class="prop-field"><label>To</label><input value="${toName}" readonly style="opacity:.5"></div>
<div class="prop-field"><label>Label</label><input value="${c.label||''}" onchange="snap();connections.find(x=>x.connId==='${cid}').label=this.value;renderConns();"></div>
<div class="prop-field"><label>Color</label><input type="color" value="${c.color||'#4a9eff'}" onchange="snap();connections.find(x=>x.connId==='${cid}').color=this.value;renderConns();"></div>
<div class="prop-field"><label>Transition</label><select onchange="snap();connections.find(x=>x.connId==='${cid}').transition=this.value;">
<option value="" ${!c.transition?'selected':''}>Default (use global)</option>
<option value="cut" ${c.transition==='cut'?'selected':''}>Cut (instant)</option>
<option value="fade" ${c.transition==='fade'?'selected':''}>Fade to black</option>
<option value="fadeWhite" ${c.transition==='fadeWhite'?'selected':''}>Fade to white</option>
<option value="dissolve" ${c.transition==='dissolve'?'selected':''}>Dissolve (crossfade)</option>
<option value="slideLeft" ${c.transition==='slideLeft'?'selected':''}>Slide Left</option>
<option value="slideRight" ${c.transition==='slideRight'?'selected':''}>Slide Right</option>
<option value="zoomIn" ${c.transition==='zoomIn'?'selected':''}>Zoom In</option>
<option value="blur" ${c.transition==='blur'?'selected':''}>Blur</option>
</select></div>
</div>
<div class="prop-group"><button class="prop-btn danger" onclick="delConn('${cid}')"><i class="bi bi-trash3"></i> Delete Connection</button></div>
`;
}
// ===== INTERACTION CRUD =====
function intIco(t){return{hotspot:'cursor-fill',choice:'signpost-split',button:'hand-index',overlay:'layers',timer:'clock',text:'fonts',shape:'square',image:'image'}[t]||'star';}
function openAddInt(nid){
if(!nid&&selNode)nid=selNode;if(!nid){toast('Select a node first','err');return;}
addIntNode=nid;editIntId=null;
const n=nodes.find(x=>x.nodeId===nid);const dur=n?.duration||30;
document.getElementById('miTitle').textContent='Add Element';
document.getElementById('miBody').innerHTML=`
<div class="prop-field"><label>Type</label><select id="mi_type">
<optgroup label="Display Elements">
<option value="text">Text</option>
<option value="shape">Shape</option>
<option value="image">Image</option>
</optgroup>
<optgroup label="Interactive Elements">
<option value="button" selected>Button</option>
<option value="hotspot">Hotspot</option>
<option value="choice">Choice</option>
<option value="overlay">Overlay Popup</option>
<option value="timer">Timer</option>
</optgroup>
</select></div>
<div class="prop-field"><label>Label / Content</label><input id="mi_label" placeholder="e.g. Go left or text to display"></div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>Start (s)</label><input type="number" id="mi_start" value="0" min="0" max="${dur}" step="0.1"></div>
<div class="prop-field" style="flex:1"><label>End (s)</label><input type="number" id="mi_end" value="${Math.min(5,dur)}" min="0" max="${dur}" step="0.1"></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>X %</label><input type="number" id="mi_x" value="50" min="0" max="100"></div>
<div class="prop-field" style="flex:1"><label>Y %</label><input type="number" id="mi_y" value="50" min="0" max="100"></div>
</div>
<div style="display:flex;gap:8px">
<div class="prop-field" style="flex:1"><label>W %</label><input type="number" id="mi_w" value="15" min="1" max="100"></div>
<div class="prop-field" style="flex:1"><label>H %</label><input type="number" id="mi_h" value="8" min="1" max="100"></div>
</div>
<div class="prop-field"><label>Background</label><input type="color" id="mi_bg" value="#4a9eff"></div>
<div class="prop-field"><label>Text / Foreground Color</label><input type="color" id="mi_clr" value="#ffffff"></div>
<div class="prop-field"><label>Icon (bi-xxx, optional)</label><input id="mi_icon" placeholder="bi-cursor-fill"></div>
<div class="prop-field"><label>Image (PNG, GIF, APNG, WebP)</label>
<input type="file" id="mi_imgFile" accept="image/*" onchange="handleIntImage(this)" style="font-size:11px">
<input type="hidden" id="mi_imgData" value="">
<div id="mi_imgPreview" style="margin-top:4px"></div>
</div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="mi_clickable" checked> Clickable (interaction trigger)</label></div>
<div class="prop-field"><label class="prop-check"><input type="checkbox" id="mi_pause" checked> Pause video on trigger</label></div>
`;
document.getElementById('modalInt').classList.add('show');
}
function handleIntImage(input){
if(!input.files||!input.files[0])return;
const reader=new FileReader();
reader.onload=e=>{
document.getElementById('mi_imgData').value=e.target.result;
document.getElementById('mi_imgPreview').innerHTML=`<img src="${e.target.result}" style="max-width:80px;max-height:40px;border-radius:4px;border:1px solid var(--border-dim)">`;
};
reader.readAsDataURL(input.files[0]);
}
function saveInt(){
snap();
const d={
intId:editIntId||gid(), nodeId:addIntNode,
type:document.getElementById('mi_type').value,
label:document.getElementById('mi_label').value,
startTime:+document.getElementById('mi_start').value||0,
endTime:+document.getElementById('mi_end').value||5,
x:+document.getElementById('mi_x').value||50, y:+document.getElementById('mi_y').value||50,
w:+document.getElementById('mi_w').value||15, h:+document.getElementById('mi_h').value||8,
clickable:document.getElementById('mi_clickable').checked,
action:{type:'jumpToVideo',value:'',pauseVideo:document.getElementById('mi_pause').checked},
style:{bg:document.getElementById('mi_bg').value,color:document.getElementById('mi_clr').value,icon:document.getElementById('mi_icon').value,image:document.getElementById('mi_imgData').value||''}
};
if(editIntId){const idx=interactions.findIndex(i=>i.intId===editIntId);if(idx>=0)interactions[idx]={...interactions[idx],...d};}
else interactions.push(d);
editIntId=null;closeMod('modalInt');fullRender();selectNode(addIntNode);toast('Element saved');
}
// ===== TIMELINE ZOOM =====
let tlPPS=18;
function tlZoom(d){tlPPS=Math.max(2,Math.min(120,tlPPS+d));document.getElementById('tlZoomLbl').textContent=tlPPS+'px/s';if(selNode)showTimeline(selNode);}
function removeInt(iid){snap();connections=connections.filter(c=>c.fromPortId!==iid);interactions=interactions.filter(i=>i.intId!==iid);if(selInt===iid)selInt=null;fullRender();if(selNode)selectNode(selNode);toast('Removed');}
// ===== NODE CRUD =====
function deleteNode(nid){
snap();const ints=interactions.filter(i=>i.nodeId===nid).map(i=>i.intId);
connections=connections.filter(c=>c.toNodeId!==nid&&c.fromPortId!==nid&&!ints.includes(c.fromPortId));
interactions=interactions.filter(i=>i.nodeId!==nid);
nodes=nodes.filter(n=>n.nodeId!==nid);
if(project.startNodeId===nid)project.startNodeId=nodes.length?nodes[0].nodeId:null;
clearSel();fullRender();toast('Deleted');
}
function delConn(cid){snap();connections=connections.filter(c=>c.connId!==cid);clearSel();fullRender();toast('Connection removed');}
// ===== CONTEXT MENU =====
let ctxNid=null;
function showCtx(x,y,nid){ctxNid=nid;const m=document.getElementById('ctxMenu');m.style.left=x+'px';m.style.top=y+'px';m.classList.add('show');}
function hideCtx(){document.getElementById('ctxMenu').classList.remove('show');}
function ctxAct(a){
hideCtx();if(!ctxNid)return;
if(a==='start'){snap();project.startNodeId=ctxNid;fullRender();toast('Start set');}
else if(a==='dup'){snap();const o=nodes.find(n=>n.nodeId===ctxNid);if(o){const nn={...o,nodeId:gid(),name:o.name+' (copy)',x:o.x+30,y:o.y+30};nodes.push(nn);fullRender();selectNode(nn.nodeId);toast('Duplicated');}}
else if(a==='del')deleteNode(ctxNid);
}
// ===== TIMELINE =====
function showTimeline(nid){
const n=nodes.find(x=>x.nodeId===nid);if(!n)return;
document.getElementById('tlTitle').textContent=`Timeline — ${n.name}`;
document.getElementById('btnAddInt').style.display='flex';
document.getElementById('btnAddInt').onclick=()=>openAddInt(nid);
document.getElementById('tlEmpty').style.display='none';
document.getElementById('tlContent').style.display='block';
const dur=Math.max(n.duration||30,5);
const PPS=tlPPS;
const totalW=dur*PPS+40;
const ruler=document.getElementById('tlRuler');ruler.style.width=totalW+'px';ruler.innerHTML='';
const step=dur>120?10:dur>30?5:1;
for(let t=0;t<=dur;t+=step){const tk=document.createElement('div');tk.className='tl-tick';tk.style.left=(t*PPS)+'px';const lb=document.createElement('div');lb.className='tl-tick-label';lb.textContent=fmt(t);tk.appendChild(lb);ruler.appendChild(tk);}
const tracks=document.getElementById('tlTracks');tracks.style.width=totalW+'px';tracks.innerHTML='';
const ints=interactions.filter(i=>i.nodeId===nid);
const nodeIntIndices=[];// map: which global index each node-int is at
interactions.forEach((it,idx)=>{if(it.nodeId===nid)nodeIntIndices.push(idx);});
// Reverse visual order so higher layer is at TOP of timeline (matches z-index intuition)
const TRACK_H=28;
const orderedInts=[...ints].reverse(); // top in UI = last in array = on top in z-order
orderedInts.forEach((intr,vIdx)=>{
const track=document.createElement('div');track.className='tl-track';
track.style.position='relative';track.style.height=TRACK_H+'px';
const bar=document.createElement('div');
bar.className=`tl-bar type-${intr.type} ${selInt===intr.intId?'selected':''}`;
bar.style.left=(intr.startTime*PPS)+'px';
bar.style.width=Math.max(20,(intr.endTime-intr.startTime)*PPS)+'px';
// Z-layer indicator (higher index = on top). Show as "L1, L2..." with L1 being TOP
const zLayer=ints.length-vIdx;
bar.innerHTML=`<span style="opacity:0.6;font-size:9px;margin-right:4px">L${zLayer}</span>${intr.label||intr.type}`;
const hl=document.createElement('div');hl.className='tl-handle tl-handle-l';
const hr=document.createElement('div');hr.className='tl-handle tl-handle-r';
bar.appendChild(hl);bar.appendChild(hr);
bar.addEventListener('mousedown',e=>{
if(e.target.classList.contains('tl-handle'))return;
e.stopPropagation();
selectInteraction(intr.intId);
const startX=e.clientX,startY=e.clientY;
const bDrag={iid:intr.intId,sx:startX,sy:startY,os:intr.startTime,oe:intr.endTime,startVIdx:vIdx,moved:false,mode:null};
const onM=ev=>{
const dx=ev.clientX-startX,dy=ev.clientY-startY;
if(!bDrag.mode){
if(Math.abs(dx)>3||Math.abs(dy)>3){bDrag.moved=true;
// Determine mode: vertical for layer, horizontal for time
bDrag.mode=Math.abs(dy)>Math.abs(dx)?'layer':'time';
}else return;
}
const it=interactions.find(i=>i.intId===bDrag.iid);if(!it)return;
if(bDrag.mode==='time'){
const dxT=(ev.clientX-bDrag.sx)/PPS;
const len=bDrag.oe-bDrag.os;
let ns=Math.max(0,bDrag.os+dxT);if(ns+len>dur)ns=dur-len;
it.startTime=Math.round(ns*10)/10;it.endTime=Math.round((ns+len)*10)/10;
bar.style.left=(it.startTime*PPS)+'px';bar.style.width=Math.max(20,(it.endTime-it.startTime)*PPS)+'px';
bar.style.transform='';
}else{
// Layer drag — show visual feedback
bar.style.transform=`translateY(${dy}px)`;
bar.style.zIndex='100';bar.style.opacity='0.85';
}
};
const onU=ev=>{
document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);
bar.style.transform='';bar.style.zIndex='';bar.style.opacity='';
if(bDrag.mode==='layer'&&bDrag.moved){
const dy=ev.clientY-startY;
const shift=Math.round(dy/TRACK_H);
if(shift!==0){
snap();
// Moving DOWN in the UI (positive shift) means LOWER in z-layer
// Moving UP in UI means HIGHER in z-layer
// Visual index vIdx goes 0..n-1 from top to bottom. Bottom in UI = first in array = lowest z.
// We need to reorder the underlying interactions array.
const curIdx=nodeIntIndices[ints.length-1-bDrag.startVIdx]; // actual array index of this int
const newVIdx=Math.max(0,Math.min(ints.length-1,bDrag.startVIdx+shift));
const newArrayPos=ints.length-1-newVIdx; // which position among this node's ints
const targetIdx=nodeIntIndices[newArrayPos];
// Remove from curIdx, insert at targetIdx
const [moved]=interactions.splice(curIdx,1);
// Adjust targetIdx if it was after curIdx
const insertAt=targetIdx>curIdx?targetIdx:targetIdx;
interactions.splice(insertAt,0,moved);
fullRender();selectInteraction(moved.intId);
return;
}
}else if(bDrag.mode==='time'&&bDrag.moved){
snap();
}
if(selInt)selectInteraction(selInt);else if(selNode)selectNode(selNode);
};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
});
hl.addEventListener('mousedown',e=>{
e.stopPropagation();
const bDrag={iid:intr.intId,sx:e.clientX,os:intr.startTime};
const onM=ev=>{
const dx=(ev.clientX-bDrag.sx)/PPS;
const it=interactions.find(i=>i.intId===bDrag.iid);if(!it)return;
it.startTime=Math.max(0,Math.min(it.endTime-0.1,Math.round((bDrag.os+dx)*10)/10));
bar.style.left=(it.startTime*PPS)+'px';bar.style.width=Math.max(20,(it.endTime-it.startTime)*PPS)+'px';
};
const onU=()=>{snap();document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);fullRender();if(selInt)selectInteraction(selInt);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
});
hr.addEventListener('mousedown',e=>{
e.stopPropagation();
const bDrag={iid:intr.intId,sx:e.clientX,oe:intr.endTime};
const onM=ev=>{
const dx=(ev.clientX-bDrag.sx)/PPS;
const it=interactions.find(i=>i.intId===bDrag.iid);if(!it)return;
it.endTime=Math.max(it.startTime+0.1,Math.min(dur,Math.round((bDrag.oe+dx)*10)/10));
bar.style.width=Math.max(20,(it.endTime-it.startTime)*PPS)+'px';
};
const onU=()=>{snap();document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);fullRender();if(selInt)selectInteraction(selInt);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
});
track.appendChild(bar);tracks.appendChild(track);
});
if(!ints.length){const e=document.createElement('div');e.style.cssText='padding:14px;text-align:center;font-size:11px;color:var(--text-dim);';e.textContent='No elements. Click "+ Add" above.';tracks.appendChild(e);}
else{
// Helper hint
const hint=document.createElement('div');hint.style.cssText='padding:6px 10px;font-size:10px;color:var(--text-dim);border-top:1px dashed var(--border-dim);margin-top:4px';
hint.innerHTML='<i class="bi bi-info-circle"></i> Drag bars horizontally to change time, vertically to change layer order (L1 = top, on top of others)';
tracks.appendChild(hint);
}
}
// ===== CANVAS PAN/ZOOM =====
let cPan=null;
document.getElementById('canvas').addEventListener('mousedown',e=>{
// Don't intercept clicks on nodes, controls, SVG paths, or connection labels
if(e.target.closest('.vnode')||e.target.closest('.canvas-controls')||e.target.closest('.conn-label-el'))return;
if(e.target.tagName==='path'||e.target.closest('svg.conn-svg'))return;
if(e.button===0){cPan={sx:e.clientX-cOff.x,sy:e.clientY-cOff.y};clearSel();}
});
document.addEventListener('mousemove',e=>{if(cPan){cOff.x=e.clientX-cPan.sx;cOff.y=e.clientY-cPan.sy;updateNodePositions();renderConns();}});
document.addEventListener('mouseup',()=>{cPan=null;});
document.getElementById('canvas').addEventListener('wheel',e=>{e.preventDefault();doZoom(e.deltaY>0?-0.06:0.06);},{passive:false});
function doZoom(d){cZoom=Math.max(0.3,Math.min(2,cZoom+d));document.getElementById('zoomLbl').textContent=Math.round(cZoom*100)+'%';updateNodePositions();renderConns();}
function fitView(){cZoom=1;cOff={x:60,y:60};document.getElementById('zoomLbl').textContent='100%';updateNodePositions();renderConns();}
// ===== KEYBOARD =====
document.addEventListener('keydown',e=>{
if(e.target.tagName==='INPUT'||e.target.tagName==='TEXTAREA'||e.target.tagName==='SELECT')return;
if(e.key==='Delete'||e.key==='Backspace'){if(selConn)delConn(selConn);else if(selInt)removeInt(selInt);else if(selNode)deleteNode(selNode);}
if(e.ctrlKey&&e.key==='z'){e.preventDefault();doUndo();}
if(e.ctrlKey&&e.key==='y'){e.preventDefault();doRedo();}
});
document.addEventListener('click',hideCtx);
// ===== SETTINGS =====
function saveSettings(){
var cm=document.getElementById('sCtrlMode').value;
var prev=project.settings||{};
project.settings={
showControls:document.getElementById('sShowCtrl').checked,
controlMode:cm,
controlPosition:document.getElementById('sCtrlPos').value,
autoHide:cm==='custom'?(document.getElementById('sAutoHide2')||{}).checked:document.getElementById('sAutoHide').checked,
allowSeeking:cm==='custom'?(document.getElementById('sSeek2')||{}).checked:document.getElementById('sSeek').checked,
showProgress:cm==='custom'?(document.getElementById('sProgress2')||{}).checked:document.getElementById('sProgress').checked,
showPlayBtn:(document.getElementById('sShowPlay')||{checked:true}).checked,
showPauseBtn:(document.getElementById('sShowPause')||{checked:true}).checked,
showRewindBtn:(document.getElementById('sShowRewind')||{checked:true}).checked,
showForwardBtn:(document.getElementById('sShowForward')||{checked:true}).checked,
showMuteBtn:(document.getElementById('sShowMute')||{checked:true}).checked,
showFsBtn:(document.getElementById('sShowFs')||{checked:true}).checked,
showReplayBtn:(document.getElementById('sShowReplay')||{checked:true}).checked,
accentColor:document.getElementById('sAccent').value,
playIcon:document.getElementById('sPlayIcon').value,
pauseIcon:document.getElementById('sPauseIcon').value,
seekIcon:document.getElementById('sSeekIcon').value,
muteIcon:document.getElementById('sMuteIcon').value,
fsIcon:document.getElementById('sFsIcon').value,
replayIcon:document.getElementById('sReplayIcon').value,
overlayImg:document.getElementById('sOvImg').value,
overlayImgSize:document.getElementById('sOvImgSize').value,
overlayImgPos:document.getElementById('sOvImgPos').value,
overlayImgOpacity:+document.getElementById('sOvImgOp').value,
endBehavior:document.getElementById('sEnd').value,
videoFit:document.getElementById('sVideoFit').value,
videoBasePath:document.getElementById('sVideoBase').value||'./videos/',
transition:document.getElementById('sTransition').value,
transitionDuration:+document.getElementById('sTransDur').value||600,
choiceTitle:document.getElementById('sChoiceTitle').value,
choiceSubtitle:document.getElementById('sChoiceSub').value,
showChoiceOverlay:document.getElementById('sChoiceShow').checked,
loadingText:document.getElementById('sLoadText').value,
loadingBg:document.getElementById('sLoadBg').value,
autoPlay:document.getElementById('sAutoPlay').checked,
audioSource:document.getElementById('sAudioSrc').value,
bgmFile:document.getElementById('sAudioFile').value,
bgmVolume:+document.getElementById('sAudioVol').value,
// Preserve ctrlPositions from previous save (set by position editor)
ctrlPositions:prev.ctrlPositions||null
};
}
function toggleCtrlUI(){
var mode=document.getElementById('sCtrlMode').value;
document.getElementById('standardCtrlOpts').style.display=mode==='standard'?'block':'none';
document.getElementById('customCtrlOpts').style.display=mode==='custom'?'block':'none';
}
function handleOverlayImg(input){
if(!input.files||!input.files[0])return;
var r=new FileReader();r.onload=function(e){
document.getElementById('sOvImg').value=e.target.result;
document.getElementById('sOvImgPreview').innerHTML='<img src="'+e.target.result+'" style="height:20px;border-radius:2px">';
saveSettings();
};r.readAsDataURL(input.files[0]);
}
function clearOverlayImg(){document.getElementById('sOvImg').value='';document.getElementById('sOvImgPreview').textContent='None';saveSettings();}
function handleNodeOvImg(input,nid){
if(!input.files||!input.files[0])return;
var r=new FileReader();r.onload=function(e){snap();var n=nodes.find(x=>x.nodeId===nid);if(n)n.overlayImg=e.target.result;selectNode(nid);};
r.readAsDataURL(input.files[0]);
}
function handleIconUpload(input,type){
if(!input.files||!input.files[0])return;
const r=new FileReader();
r.onload=e=>{
const data=e.target.result;
if(type==='play'){document.getElementById('sPlayIcon').value=data;document.getElementById('sPlayPreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
else if(type==='pause'){document.getElementById('sPauseIcon').value=data;document.getElementById('sPausePreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
else if(type==='seek'){document.getElementById('sSeekIcon').value=data;document.getElementById('sSeekPreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
else if(type==='replay'){document.getElementById('sReplayIcon').value=data;document.getElementById('sReplayPreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
else if(type==='mute'){document.getElementById('sMuteIcon').value=data;document.getElementById('sMutePreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
else if(type==='fs'){document.getElementById('sFsIcon').value=data;document.getElementById('sFsPreview').innerHTML=`<img src="${data}" style="height:20px;border-radius:2px">`;}
saveSettings();
};
r.readAsDataURL(input.files[0]);
}
function clearIconUpload(type){
if(type==='play'){document.getElementById('sPlayIcon').value='';document.getElementById('sPlayPreview').textContent='Default';}
else if(type==='pause'){document.getElementById('sPauseIcon').value='';document.getElementById('sPausePreview').textContent='Default';}
else if(type==='seek'){document.getElementById('sSeekIcon').value='';document.getElementById('sSeekPreview').textContent='Default';}
else if(type==='replay'){document.getElementById('sReplayIcon').value='';document.getElementById('sReplayPreview').textContent='Default';}
else if(type==='mute'){document.getElementById('sMuteIcon').value='';document.getElementById('sMutePreview').textContent='Default';}
else if(type==='fs'){document.getElementById('sFsIcon').value='';document.getElementById('sFsPreview').textContent='Default';}
saveSettings();
}
// ===== POSITION EDITOR =====
let posIntId=null;
const VP_SIZES={desktop:{w:1920,h:1080},tablet:{w:1024,h:768},mobileP:{w:390,h:844},mobileL:{w:844,h:390}};
let curVP='desktop';
let posUnit='%';
let vpData={desktop:{x:50,y:50,w:15,h:8},tablet:{x:50,y:50,w:15,h:8},mobileP:{x:50,y:50,w:20,h:6},mobileL:{x:50,y:50,w:15,h:8}};
function openPosEditor(iid){
const intr=interactions.find(i=>i.intId===iid);if(!intr)return;
posIntId=iid;
document.getElementById('posTitle').textContent=intr.label||intr.type;
const saved=intr.vpPos||{};
const def={x:intr.x||50,y:intr.y||50,w:intr.w||15,h:intr.h||8};
['desktop','tablet','mobileP','mobileL'].forEach(vp=>{vpData[vp]=saved[vp]?{...saved[vp]}:{...def};});
const el=document.getElementById('posDragEl');
el.style.backgroundColor=intr.style?.transparent?'transparent':(intr.style?.bg||'#4a9eff');
el.style.color=intr.style?.color||'#fff';
Array.from(el.childNodes).forEach(n=>{if(n.nodeType===3||n.tagName==='SPAN')n.remove();});
const lbl=document.createElement('span');lbl.textContent=intr.label||intr.type;lbl.style.cssText='pointer-events:none;position:relative;z-index:1';
el.insertBefore(lbl,el.querySelector('.pos-handle'));
if(intr.style?.image){el.style.backgroundImage='url('+intr.style.image+')';el.style.backgroundSize='cover';}
else{el.style.backgroundImage='none';}
// Set video frame as background of the position editor
const node=nodes.find(n=>n.nodeId===intr.nodeId);
const frame=document.getElementById('posFrame');
if(node){
const vidSrc=node.blobUrl||node.src||(project.settings.videoBasePath||'./videos/')+(node.filename||node.name+'.mp4');
frame.style.background='#111';
let bgVid=frame.querySelector('.pos-bg-video');
if(!bgVid){bgVid=document.createElement('video');bgVid.className='pos-bg-video';bgVid.style.cssText='position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0.4;pointer-events:none;z-index:0';bgVid.muted=true;frame.insertBefore(bgVid,frame.firstChild);}
bgVid.src=vidSrc;bgVid.currentTime=0.5;
}
posUnit='%';document.getElementById('posUnitBtn').textContent='%';
document.getElementById('modalPos').classList.add('show');
curVP=null;switchVP('desktop');
}
// Render ghost versions of other elements in the same node for visual reference
function renderPosGhosts(){
const frame=document.getElementById('posFrame');if(!frame)return;
// Remove existing ghosts
frame.querySelectorAll('.pos-ghost').forEach(g=>g.remove());
const intr=interactions.find(i=>i.intId===posIntId);if(!intr)return;
const others=interactions.filter(i=>i.nodeId===intr.nodeId&&i.intId!==posIntId);
const fw=frame.clientWidth,fh=frame.clientHeight;
others.forEach(o=>{
const p=(o.vpPos&&o.vpPos[curVP])||{x:o.x||50,y:o.y||50,w:o.w||15,h:o.h||8};
const g=document.createElement('div');g.className='pos-ghost';
const s=o.style||{};
const bg=s.transparent?'rgba(100,100,100,0.15)':(s.bg||'#666');
g.style.cssText='position:absolute;pointer-events:none;z-index:1;opacity:0.35;border:1px dashed rgba(255,255,255,0.4);display:flex;align-items:center;justify-content:center;font-size:10px;color:#fff;overflow:hidden;border-radius:'+(s.borderRadius||4)+'px;'
+'left:'+((p.x-p.w/2)/100*fw)+'px;top:'+((p.y-p.h/2)/100*fh)+'px;width:'+(p.w/100*fw)+'px;height:'+(p.h/100*fh)+'px;'
+'background-color:'+bg+';';
// Render content based on type
let content='';
if(o.type==='text'){content='<span style="padding:2px;text-align:center">'+(o.label||'Text').slice(0,30)+'</span>';}
else if(o.type==='image'&&s.image){content='<img src="'+s.image+'" style="width:100%;height:100%;object-fit:contain;opacity:0.8">';}
else if(o.type==='shape'){
if(s.shapeType==='circle')g.style.borderRadius='50%';
else if(s.shapeType==='rounded')g.style.borderRadius=Math.max(s.borderRadius||4,16)+'px';
content='<span style="font-size:9px;opacity:0.7">'+(o.label||o.type)+'</span>';
}
else{content='<span>'+(o.label||o.type)+'</span>';}
g.innerHTML=content;
frame.insertBefore(g,frame.querySelector('#posDragEl'));
});
}
function togglePosUnit(){
saveCurrentVP();const vp=VP_SIZES[curVP];const d=vpData[curVP];
if(posUnit==='%'){posUnit='px';document.getElementById('posUnitBtn').textContent='px';
document.getElementById('posX').value=Math.round(d.x/100*vp.w);document.getElementById('posY').value=Math.round(d.y/100*vp.h);
document.getElementById('posW').value=Math.round(d.w/100*vp.w);document.getElementById('posH').value=Math.round(d.h/100*vp.h);
}else{posUnit='%';document.getElementById('posUnitBtn').textContent='%';
document.getElementById('posX').value=Math.round(d.x);document.getElementById('posY').value=Math.round(d.y);
document.getElementById('posW').value=Math.round(d.w);document.getElementById('posH').value=Math.round(d.h);
}
renderPosEl();
}
function saveCurrentVP(){
if(!curVP)return;const vp=VP_SIZES[curVP];
if(posUnit==='px'){
vpData[curVP]={x:Math.round(+document.getElementById('posX').value/vp.w*100),y:Math.round(+document.getElementById('posY').value/vp.h*100),w:Math.round(+document.getElementById('posW').value/vp.w*100),h:Math.round(+document.getElementById('posH').value/vp.h*100)};
}else{
vpData[curVP]={x:+document.getElementById('posX').value||50,y:+document.getElementById('posY').value||50,w:+document.getElementById('posW').value||15,h:+document.getElementById('posH').value||8};
}
}
function switchVP(vp){
if(curVP&&posIntId)saveCurrentVP();
curVP=vp;
document.querySelectorAll('[id^="vp"]').forEach(b=>b.classList.remove('primary'));
({desktop:'vpDesk',tablet:'vpTab',mobileP:'vpMobP',mobileL:'vpMobL'})[vp]&&document.getElementById(({desktop:'vpDesk',tablet:'vpTab',mobileP:'vpMobP',mobileL:'vpMobL'})[vp]).classList.add('primary');
const sz=VP_SIZES[vp];const container=document.getElementById('posViewport');
const maxW=container.clientWidth-16,maxH=420;
const scale=Math.min(maxW/sz.w,maxH/sz.h);
const frame=document.getElementById('posFrame');
frame.style.width=Math.round(sz.w*scale)+'px';frame.style.height=Math.round(sz.h*scale)+'px';
const d=vpData[vp];
if(posUnit==='px'){
document.getElementById('posX').value=Math.round(d.x/100*sz.w);document.getElementById('posY').value=Math.round(d.y/100*sz.h);
document.getElementById('posW').value=Math.round(d.w/100*sz.w);document.getElementById('posH').value=Math.round(d.h/100*sz.h);
}else{
document.getElementById('posX').value=Math.round(d.x);document.getElementById('posY').value=Math.round(d.y);
document.getElementById('posW').value=Math.round(d.w);document.getElementById('posH').value=Math.round(d.h);
}
renderPosEl();
}
function renderPosEl(){
const frame=document.getElementById('posFrame'),el=document.getElementById('posDragEl');
const d=vpData[curVP];const fw=frame.clientWidth,fh=frame.clientHeight;
el.style.width=Math.round(d.w/100*fw)+'px';el.style.height=Math.round(d.h/100*fh)+'px';
el.style.left=Math.round((d.x-d.w/2)/100*fw)+'px';el.style.top=Math.round((d.y-d.h/2)/100*fh)+'px';
el.style.zIndex='10';// above ghosts
document.getElementById('posCoords').textContent=curVP+': x:'+Math.round(d.x)+'% y:'+Math.round(d.y)+'% w:'+Math.round(d.w)+'% h:'+Math.round(d.h)+'%';
renderPosGhosts();
el.onmousedown=e=>{
if(e.target.classList.contains('pos-handle')){
e.preventDefault();e.stopPropagation();
const cls=e.target.className;const sL=el.offsetLeft,sT=el.offsetTop,sW=el.clientWidth,sH=el.clientHeight;
const onM=ev=>{
let nL=sL,nT=sT,nW=sW,nH=sH;const dx=ev.clientX-e.clientX,dy=ev.clientY-e.clientY;
if(cls.includes('h-br')){nW=Math.max(20,sW+dx);nH=Math.max(14,sH+dy);}
else if(cls.includes('h-bl')){nL=sL+dx;nW=Math.max(20,sW-dx);nH=Math.max(14,sH+dy);}
else if(cls.includes('h-tr')){nT=sT+dy;nW=Math.max(20,sW+dx);nH=Math.max(14,sH-dy);}
else if(cls.includes('h-tl')){nL=sL+dx;nT=sT+dy;nW=Math.max(20,sW-dx);nH=Math.max(14,sH-dy);}
else if(cls.includes('h-tm')){nT=sT+dy;nH=Math.max(14,sH-dy);}
else if(cls.includes('h-bm')){nH=Math.max(14,sH+dy);}
else if(cls.includes('h-ml')){nL=sL+dx;nW=Math.max(20,sW-dx);}
else if(cls.includes('h-mr')){nW=Math.max(20,sW+dx);}
nL=Math.max(0,nL);nT=Math.max(0,nT);
el.style.left=nL+'px';el.style.top=nT+'px';el.style.width=nW+'px';el.style.height=nH+'px';
const wp=nW/fw*100,hp=nH/fh*100,xp=nL/fw*100+wp/2,yp=nT/fh*100+hp/2;
vpData[curVP]={x:Math.round(xp*10)/10,y:Math.round(yp*10)/10,w:Math.round(wp*10)/10,h:Math.round(hp*10)/10};
updPosInputs();
};
const onU=()=>{document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);return;
}
e.preventDefault();const fr=frame.getBoundingClientRect();
const ox=e.clientX-el.getBoundingClientRect().left,oy=e.clientY-el.getBoundingClientRect().top;
const onM=ev=>{
const cx=Math.max(0,Math.min(fw-el.clientWidth,ev.clientX-fr.left-ox));
const cy=Math.max(0,Math.min(fh-el.clientHeight,ev.clientY-fr.top-oy));
el.style.left=cx+'px';el.style.top=cy+'px';
const wp=el.clientWidth/fw*100,hp=el.clientHeight/fh*100;
vpData[curVP].x=Math.round((cx/fw*100+wp/2)*10)/10;vpData[curVP].y=Math.round((cy/fh*100+hp/2)*10)/10;
updPosInputs();
};
const onU=()=>{document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
};
}
function updPosInputs(){
const d=vpData[curVP],vp=VP_SIZES[curVP];
if(posUnit==='px'){
document.getElementById('posX').value=Math.round(d.x/100*vp.w);document.getElementById('posY').value=Math.round(d.y/100*vp.h);
document.getElementById('posW').value=Math.round(d.w/100*vp.w);document.getElementById('posH').value=Math.round(d.h/100*vp.h);
}else{
document.getElementById('posX').value=Math.round(d.x);document.getElementById('posY').value=Math.round(d.y);
document.getElementById('posW').value=Math.round(d.w);document.getElementById('posH').value=Math.round(d.h);
}
document.getElementById('posCoords').textContent=curVP+': x:'+Math.round(d.x)+'% y:'+Math.round(d.y)+'% w:'+Math.round(d.w)+'% h:'+Math.round(d.h)+'%';
}
function updatePosFromInput(){saveCurrentVP();renderPosEl();}
function savePosEditor(){
if(!posIntId)return;saveCurrentVP();snap();
const intr=interactions.find(i=>i.intId===posIntId);
if(intr){
const dd=vpData.desktop;intr.x=dd.x;intr.y=dd.y;intr.w=dd.w;intr.h=dd.h;
intr.vpPos={desktop:{...vpData.desktop},tablet:{...vpData.tablet},mobileP:{...vpData.mobileP},mobileL:{...vpData.mobileL}};
}
closeMod('modalPos');fullRender();selectInteraction(posIntId);toast('Positions saved for all viewports');
}
// ===== SERVER BROWSER =====
function openServerBrowser(){
document.getElementById('srvPath').value=project.settings.videoBasePath||'./videos/';
document.getElementById('modalServer').classList.add('show');
}
function scanServerDir(){
const path=document.getElementById('srvPath').value.trim();
if(!path){toast('Enter a path','err');return;}
const list=document.getElementById('srvList');
list.innerHTML='<div style="padding:20px;text-align:center"><div class="spin" style="width:24px;height:24px;border-width:2px;margin:0 auto"></div><div style="margin-top:8px;font-size:11px;color:var(--text-dim)">Scanning...</div></div>';
// Try fetching as directory listing (HTML) or JSON index
fetch(path).then(r=>r.text()).then(html=>{
const files=[];
const dirs=[];
// Try JSON first
try{
const json=JSON.parse(html);
if(Array.isArray(json))json.forEach(f=>{if(typeof f==='string')files.push(f);else if(f.name)files.push(f.name);});
}catch(e){
// Parse HTML directory listing (Apache/nginx style)
const parser=new DOMParser();
const doc=parser.parseFromString(html,'text/html');
doc.querySelectorAll('a').forEach(a=>{
const href=a.getAttribute('href');
if(!href||href==='../'||href==='../') return;
if(href.endsWith('/'))dirs.push(href);
else if(/\.(mp4|webm|mov|avi|mkv|m4v)$/i.test(href))files.push(href);
});
}
renderServerList(path,dirs,files);
}).catch(e=>{
list.innerHTML='<div style="padding:20px;text-align:center;color:var(--accent-red);font-size:12px"><i class="bi bi-exclamation-circle"></i> Could not access directory. Make sure directory listing is enabled on your server, or place a JSON index file.<br><br><span style="color:var(--text-dim)">'+e.message+'</span></div>';
});
}
function renderServerList(basePath,dirs,files){
const list=document.getElementById('srvList');
if(!dirs.length&&!files.length){
list.innerHTML='<div style="padding:20px;text-align:center;color:var(--text-dim);font-size:12px">No video files found in this directory</div>';
return;
}
let html='';
// Parent dir
if(basePath!=='./videos/'&&basePath!=='/'){
const parent=basePath.replace(/[^/]+\/$/,'')||'./';
html+=`<div class="lib-item" onclick="document.getElementById('srvPath').value='${parent}';scanServerDir()" style="cursor:pointer"><div class="thumb" style="background:var(--accent-blue-dim);color:var(--accent-blue)"><i class="bi bi-arrow-up"></i></div><div class="info"><div class="name">..</div><div class="meta">Parent directory</div></div></div>`;
}
dirs.forEach(d=>{
const fullPath=basePath+(basePath.endsWith('/')?'':'/')+d;
html+=`<div class="lib-item" onclick="document.getElementById('srvPath').value='${fullPath}';scanServerDir()" style="cursor:pointer"><div class="thumb" style="background:var(--accent-blue-dim);color:var(--accent-blue)"><i class="bi bi-folder-fill"></i></div><div class="info"><div class="name">${d}</div><div class="meta">Directory</div></div></div>`;
});
files.forEach(f=>{
const fullPath=basePath+(basePath.endsWith('/')?'':'/')+f;
html+=`<div class="lib-item" style="cursor:pointer" onclick="addServerVideo('${fullPath}','${f}')"><div class="thumb"><video src="${fullPath}" muted preload="metadata" style="width:100%;height:100%;object-fit:cover"></video></div><div class="info"><div class="name">${f}</div><div class="meta">${fullPath}</div></div><div class="actions"><button onclick="event.stopPropagation();addServerVideo('${fullPath}','${f}')" style="background:none;border:none;color:var(--accent-green);cursor:pointer;font-size:14px"><i class="bi bi-plus-circle"></i></button></div></div>`;
});
list.innerHTML=html;
}
function addServerVideo(src,filename){
const libId=gid();
const name=filename.replace(/\.[^.]+$/,'');
const item={libId,name,filename,blobUrl:'',src:src,duration:0};
// Try to get duration
const v=document.createElement('video');v.src=src;v.preload='metadata';
v.onloadedmetadata=()=>{item.duration=v.duration;renderLib();};
library.push(item);
renderLib();
toast('Added: '+name);
}
function rescanLibraryPaths(){
snap();
const basePath=project.settings.videoBasePath||'./videos/';
let updated=0;
library.forEach(l=>{
if(!l.filename)l.filename=l.name+'.mp4';
const newSrc=basePath+l.filename;
if(l.src!==newSrc){l.src=newSrc;updated++;}
});
// Also update nodes
nodes.forEach(n=>{
if(!n.filename&&n.name)n.filename=n.name+'.mp4';
if(n.filename){n.src=basePath+n.filename;}
});
renderLib();fullRender();
toast('Rebuilt '+updated+' paths using base: '+basePath);
}
// ===== CONTROL POSITION EDITOR =====
const CTRL_BTNS=['play','rewind','forward','mute','fullscreen','replay'];
const CTRL_LABELS={play:'▶ Play',rewind:'⏪ Rew',forward:'⏩ Fwd',mute:'🔊 Vol',fullscreen:'⛶ FS',replay:'↻ Replay'};
const CTRL_DEFAULTS={
play:{x:50,y:50,w:5,h:8},rewind:{x:40,y:50,w:4,h:7},forward:{x:60,y:50,w:4,h:7},
mute:{x:8,y:92,w:3,h:5},fullscreen:{x:95,y:92,w:3,h:5},replay:{x:50,y:50,w:5,h:8}
};
let ctrlVPData={};
let ctrlCurVP='desktop';
function openCtrlPosEditor(){
// Load saved positions or defaults
const saved=project.settings.ctrlPositions||{};
['desktop','tablet','mobileP','mobileL'].forEach(vp=>{
ctrlVPData[vp]=saved[vp]?{...saved[vp]}:{};
CTRL_BTNS.forEach(btn=>{
if(!ctrlVPData[vp][btn])ctrlVPData[vp][btn]={...CTRL_DEFAULTS[btn]};
});
});
document.getElementById('modalCtrlPos').classList.add('show');
ctrlCurVP=null;
switchCtrlVP('desktop');
}
function switchCtrlVP(vp){
if(ctrlCurVP)saveCtrlVPFromDOM();
ctrlCurVP=vp;
['vpDesk','vpTab','vpMobP','vpMobL'].forEach(id=>{
const el=document.getElementById('c'+id);if(el)el.classList.remove('primary');
});
const map={desktop:'cvpDesk',tablet:'cvpTab',mobileP:'cvpMobP',mobileL:'cvpMobL'};
document.getElementById(map[vp]).classList.add('primary');
const sz=VP_SIZES[vp];
const container=document.getElementById('ctrlPosViewport');
const maxW=container.clientWidth-16,maxH=420;
const scale=Math.min(maxW/sz.w,maxH/sz.h);
const fw=Math.round(sz.w*scale),fh=Math.round(sz.h*scale);
const frame=document.getElementById('ctrlPosFrame');
frame.style.width=fw+'px';frame.style.height=fh+'px';
renderCtrlBtns(frame,fw,fh);
}
function renderCtrlBtns(frame,fw,fh){
frame.innerHTML='';
const data=ctrlVPData[ctrlCurVP];
CTRL_BTNS.forEach(btn=>{
const d=data[btn]||CTRL_DEFAULTS[btn];
const el=document.createElement('div');
el.dataset.btn=btn;
const icon=project.settings[btn+'Icon'];
el.style.cssText='position:absolute;cursor:move;border:2px dashed rgba(255,255,255,0.5);border-radius:6px;display:flex;align-items:center;justify-content:center;font-size:10px;font-weight:600;color:#fff;z-index:5;overflow:hidden;'
+'left:'+Math.round((d.x-d.w/2)/100*fw)+'px;top:'+Math.round((d.y-d.h/2)/100*fh)+'px;'
+'width:'+Math.round(d.w/100*fw)+'px;height:'+Math.round(d.h/100*fh)+'px;'
+'background:rgba(0,0,0,0.7);';
if(icon){el.innerHTML='<img src="'+icon+'" style="width:80%;height:80%;object-fit:contain">';}
else{el.textContent=CTRL_LABELS[btn];}
// Resize handle
const rh=document.createElement('div');
rh.className='ctrl-rh';
rh.style.cssText='position:absolute;bottom:-4px;right:-4px;width:12px;height:12px;background:#fff;border:2px solid var(--accent-blue);border-radius:2px;cursor:se-resize;z-index:6';
el.appendChild(rh);
// Drag
el.onmousedown=function(e){
if(e.target===rh){
e.preventDefault();e.stopPropagation();
const sW=el.clientWidth,sH=el.clientHeight;
const onM=function(ev){
el.style.width=Math.max(16,sW+(ev.clientX-e.clientX))+'px';
el.style.height=Math.max(12,sH+(ev.clientY-e.clientY))+'px';
};
const onU=function(){document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
return;
}
e.preventDefault();
const fr=frame.getBoundingClientRect();
const ox=e.clientX-el.getBoundingClientRect().left,oy=e.clientY-el.getBoundingClientRect().top;
const onM=function(ev){
el.style.left=Math.max(0,Math.min(fw-el.clientWidth,ev.clientX-fr.left-ox))+'px';
el.style.top=Math.max(0,Math.min(fh-el.clientHeight,ev.clientY-fr.top-oy))+'px';
};
const onU=function(){document.removeEventListener('mousemove',onM);document.removeEventListener('mouseup',onU);};
document.addEventListener('mousemove',onM);document.addEventListener('mouseup',onU);
};
frame.appendChild(el);
});
}
function saveCtrlVPFromDOM(){
const frame=document.getElementById('ctrlPosFrame');
const fw=frame.clientWidth,fh=frame.clientHeight;
frame.querySelectorAll('[data-btn]').forEach(el=>{
const btn=el.dataset.btn;
const wp=el.clientWidth/fw*100,hp=el.clientHeight/fh*100;
const xp=el.offsetLeft/fw*100+wp/2,yp=el.offsetTop/fh*100+hp/2;
ctrlVPData[ctrlCurVP][btn]={x:Math.round(xp*10)/10,y:Math.round(yp*10)/10,w:Math.round(wp*10)/10,h:Math.round(hp*10)/10};
});
}
function saveCtrlPositions(){
saveCtrlVPFromDOM();
snap();
project.settings.ctrlPositions={
desktop:{...ctrlVPData.desktop},tablet:{...ctrlVPData.tablet},
mobileP:{...ctrlVPData.mobileP},mobileL:{...ctrlVPData.mobileL}
};
closeMod('modalCtrlPos');
toast('Control positions saved for all viewports');
}
// ===== EXPORT/IMPORT =====
function buildExport(){
const basePath=project.settings.videoBasePath||'./videos/';
const vids=nodes.map(n=>{
const fn=n.filename||n.name+'.mp4';
const src=n.src||(basePath+fn);
return{id:n.nodeId,name:n.name,src:src,filename:fn,duration:n.duration,nodeX:n.x,nodeY:n.y,startTransition:n.startTransition||'',endTransition:n.endTransition||'',endBehavior:n.endBehavior||'',audioMode:n.audioMode||'',audioFile:n.audioFile||'',audioLoop:n.audioLoop!==false,overlayImg:n.overlayImg||''};
});
const conns=connections.map(c=>{const owner=portOwner(c.fromPortId);const isInt=!!interactions.find(i=>i.intId===c.fromPortId);return{id:c.connId,fromVideoId:owner,toVideoId:c.toNodeId,label:c.label||'',color:c.color,fromPortId:c.fromPortId,isDirectFlow:!isInt,transition:c.transition||''};});
const ints=interactions.map(i=>{const cn=connections.find(c=>c.fromPortId===i.intId);const s=i.style||{};return{id:i.intId,videoId:i.nodeId,type:i.type,label:i.label,startTime:i.startTime,endTime:i.endTime,x:i.x,y:i.y,w:i.w,h:i.h,clickable:i.clickable!==false,action:{type:cn?'jumpToVideo':(i.action?.type||'showOverlay'),targetVideoId:cn?cn.toNodeId:null,value:i.action?.value||'',pauseVideo:i.action?.pauseVideo!==false},style:{backgroundColor:s.transparent?'transparent':s.bg||'#4a9eff',textColor:s.color||'#fff',icon:s.icon||'',image:s.image||'',borderWidth:s.borderWidth||0,borderColor:s.borderColor||'#fff',borderRadius:s.borderRadius||8,shadow:s.shadow||'',transparent:!!s.transparent,animateIn:s.animateIn||'',animateOut:s.animateOut||'',animateAttention:s.animateAttention||'',fontSize:s.fontSize||16,fontWeight:s.fontWeight||'600',fontStyle:s.fontStyle||'normal',textAlign:s.textAlign||'center',fontFamily:s.fontFamily||'',verticalAlign:s.verticalAlign||'center',textDecoration:s.textDecoration||'none',lineHeight:s.lineHeight||1.3,letterSpacing:s.letterSpacing||0,textTransform:s.textTransform||'none',textShadow:s.textShadow||'',customCss:s.customCss||'',opacity:s.opacity!==undefined?s.opacity:1,shapeType:s.shapeType||''},vpPos:i.vpPos||null};});
// Strip blobUrls from _admin (session-only, can't survive save/load)
// Sync project name from input
project.name=document.getElementById('projectName').value;
const adminLib=library.map(l=>({libId:l.libId,name:l.name,filename:l.filename||l.name+'.mp4',src:l.src||'',duration:l.duration}));
const adminNodes=nodes.map(n=>({...n,blobUrl:undefined}));
return{project:{...project,name:document.getElementById('projectName').value,startVideoId:project.startNodeId},videos:vids,connections:conns,interactions:ints,_admin:{library:adminLib,nodes:adminNodes,interactions,connections,project}};
}
function exportProject(){
const name=document.getElementById('projectName').value.trim()||'project';
const d=buildExport();const json=JSON.stringify(d,null,2);
const blob=new Blob([json],{type:'application/json'});const url=URL.createObjectURL(blob);
const ts=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19);
const fname=name.replace(/[^a-zA-Z0-9_-]/g,'_')+'_'+ts+'.json';
const a=document.createElement('a');a.href=url;a.download=fname;a.click();URL.revokeObjectURL(url);toast('Exported: '+fname);
}
function showImportModal(){document.getElementById('importJson').value='';document.getElementById('modalImport').classList.add('show');}
function loadImpFile(f){if(!f)return;const r=new FileReader();r.onload=e=>{document.getElementById('importJson').value=e.target.result;};r.readAsText(f);}
function doImport(){
try{
const d=JSON.parse(document.getElementById('importJson').value);snap();
if(d._admin){
const basePath=d._admin.project?.settings?.videoBasePath||project.settings.videoBasePath||'./videos/';
library=(d._admin.library||[]).map(l=>{
const fn=l.filename||l.src?.split('/').pop()||l.name+'.mp4';
// If src is empty/missing, compute from basePath+filename so it persists on re-export
const src=(l.src&&l.src.length>0)?l.src:(basePath+fn);
return{...l,blobUrl:'',filename:fn,src:src};
});
nodes=(d._admin.nodes||[]).map(n=>{
const fn=n.filename||n.src?.split('/').pop()||n.name+'.mp4';
const src=(n.src&&n.src.length>0)?n.src:(basePath+fn);
return{...n,blobUrl:'',filename:fn,src:src};
});
interactions=d._admin.interactions||[];
connections=(d._admin.connections||[]).map(c=>({...c,transition:c.transition||''}));
if(d._admin.project){
project.name=d._admin.project.name||d.project?.name||'';
project.startNodeId=d._admin.project.startNodeId||d.project?.startVideoId||null;
if(d._admin.project.settings)project.settings={...project.settings,...d._admin.project.settings};
}
// Also try to recover library src from nodes if nodes have better paths
library.forEach(l=>{
if(!l.src||l.src===basePath+l.filename){
// Try to find a matching node with a specific path
const node=nodes.find(n=>n.libId===l.libId||n.filename===l.filename);
if(node&&node.src&&node.src!==basePath+l.filename)l.src=node.src;
}
});
}else{
if(d.videos)nodes=d.videos.map(v=>({nodeId:v.id,libId:'',name:v.name,filename:v.filename||v.src?.split('/').pop()||v.name+'.mp4',x:v.nodeX||0,y:v.nodeY||0,duration:v.duration||0,blobUrl:'',src:v.src||'',startTransition:v.startTransition||'',endTransition:v.endTransition||'',endBehavior:v.endBehavior||'',audioMode:v.audioMode||'',audioFile:v.audioFile||'',audioLoop:v.audioLoop!==false}));
if(d.connections)connections=d.connections.map(c=>({connId:c.id,fromPortId:c.fromPortId||c.fromVideoId,toNodeId:c.toVideoId,label:c.label||'',color:c.color||'#4a9eff',transition:c.transition||''}));
if(d.interactions)interactions=d.interactions.map(i=>({intId:i.id,nodeId:i.videoId,type:i.type,label:i.label,startTime:i.startTime,endTime:i.endTime,x:i.x,y:i.y,w:i.w,h:i.h,vpPos:i.vpPos||null,clickable:i.clickable!==false,action:{type:i.action?.type,value:i.action?.value,pauseVideo:i.action?.pauseVideo},style:{bg:i.style?.backgroundColor,color:i.style?.textColor,icon:i.style?.icon,image:i.style?.image,transparent:i.style?.transparent,borderWidth:i.style?.borderWidth,borderColor:i.style?.borderColor,borderRadius:i.style?.borderRadius,shadow:i.style?.shadow,animateIn:i.style?.animateIn,animateOut:i.style?.animateOut,animateAttention:i.style?.animateAttention,fontSize:i.style?.fontSize,fontWeight:i.style?.fontWeight,fontStyle:i.style?.fontStyle,textAlign:i.style?.textAlign,fontFamily:i.style?.fontFamily,verticalAlign:i.style?.verticalAlign,textDecoration:i.style?.textDecoration,lineHeight:i.style?.lineHeight,letterSpacing:i.style?.letterSpacing,textTransform:i.style?.textTransform,textShadow:i.style?.textShadow,customCss:i.style?.customCss,opacity:i.style?.opacity!==undefined?i.style.opacity:1,shapeType:i.style?.shapeType}}));
if(d.project){project.name=d.project.name||'';project.startNodeId=d.project.startVideoId;Object.assign(project.settings,d.project.settings||{});}
}
document.getElementById('projectName').value=project.name;
// Bump _id counter past all existing IDs to prevent collisions
const allIds=[
...library.map(l=>l.libId||''),
...nodes.map(n=>n.nodeId||''),
...interactions.map(i=>i.intId||''),
...connections.map(c=>c.connId||'')
];
allIds.forEach(id=>{const m=/^_(\d+)$/.exec(id);if(m){const n=+m[1];if(n>=_id)_id=n+1;}});
loadSettingsToUI();
closeMod('modalImport');fullRender();renderLib();toast('Imported');
}catch(e){toast('Invalid JSON: '+e.message,'err');}
}
function loadSettingsToUI(){
var s=project.settings;
document.getElementById('sShowCtrl').checked=s.showControls!==false;
document.getElementById('sCtrlMode').value=s.controlMode||'standard';
document.getElementById('sCtrlPos').value=s.controlPosition||'bottom';
document.getElementById('sAutoHide').checked=s.autoHide!==false;
document.getElementById('sSeek').checked=s.allowSeeking!==false;
document.getElementById('sProgress').checked=s.showProgress!==false;
if(document.getElementById('sAutoHide2'))document.getElementById('sAutoHide2').checked=s.autoHide!==false;
if(document.getElementById('sProgress2'))document.getElementById('sProgress2').checked=s.showProgress!==false;
if(document.getElementById('sShowPlay'))document.getElementById('sShowPlay').checked=s.showPlayBtn!==false;
if(document.getElementById('sShowPause'))document.getElementById('sShowPause').checked=s.showPauseBtn!==false;
if(document.getElementById('sShowRewind'))document.getElementById('sShowRewind').checked=s.showRewindBtn!==false;
if(document.getElementById('sShowForward'))document.getElementById('sShowForward').checked=s.showForwardBtn!==false;
if(document.getElementById('sShowMute'))document.getElementById('sShowMute').checked=s.showMuteBtn!==false;
if(document.getElementById('sShowFs'))document.getElementById('sShowFs').checked=s.showFsBtn!==false;
if(document.getElementById('sShowReplay'))document.getElementById('sShowReplay').checked=s.showReplayBtn!==false;
document.getElementById('sAccent').value=s.accentColor||'#4a9eff';
document.getElementById('sPlayIcon').value=s.playIcon||'';
document.getElementById('sPauseIcon').value=s.pauseIcon||'';
document.getElementById('sSeekIcon').value=s.seekIcon||'';
document.getElementById('sMuteIcon').value=s.muteIcon||'';
document.getElementById('sFsIcon').value=s.fsIcon||'';
document.getElementById('sReplayIcon').value=s.replayIcon||'';
document.getElementById('sOvImg').value=s.overlayImg||'';
document.getElementById('sOvImgSize').value=s.overlayImgSize||'cover';
document.getElementById('sOvImgPos').value=s.overlayImgPos||'center';
document.getElementById('sOvImgOp').value=s.overlayImgOpacity||1;
document.getElementById('sAutoPlay').checked=s.autoPlay!==false;
document.getElementById('sEnd').value=s.endBehavior||'pause';
document.getElementById('sVideoFit').value=s.videoFit||'contain';
document.getElementById('sVideoBase').value=s.videoBasePath||'./videos/';
document.getElementById('sChoiceTitle').value=s.choiceTitle||'What do you choose?';
document.getElementById('sChoiceSub').value=s.choiceSubtitle||'Select an option to continue';
document.getElementById('sChoiceShow').checked=s.showChoiceOverlay!==false;
document.getElementById('sLoadText').value=s.loadingText||'Loading...';
document.getElementById('sLoadBg').value=s.loadingBg||'#000000';
document.getElementById('sAudioSrc').value=s.audioSource||'video';
document.getElementById('sAudioFile').value=s.bgmFile||'';
document.getElementById('sAudioVol').value=s.bgmVolume||0.5;
document.getElementById('sTransition').value=s.transition||'fade';
document.getElementById('sTransDur').value=s.transitionDuration||600;
// Previews
var icons=['play','pause','seek','mute','fs','replay'];
icons.forEach(function(k){var v=s[k+'Icon'];var el=document.getElementById('s'+k.charAt(0).toUpperCase()+k.slice(1)+'Preview');if(!el)return;if(v)el.innerHTML='<img src="'+v+'" style="height:20px">';else el.textContent='Default';});
if(s.playIcon)document.getElementById('sPlayPreview').innerHTML='<img src="'+s.playIcon+'" style="height:20px">';
if(s.replayIcon)document.getElementById('sReplayPreview').innerHTML='<img src="'+s.replayIcon+'" style="height:20px">';
if(s.overlayImg)document.getElementById('sOvImgPreview').innerHTML='<img src="'+s.overlayImg+'" style="height:20px">';
toggleCtrlUI();
}
function previewProject(){
const d=buildExport();
// Inject blobUrls for preview playback (these are session-only)
d.videos.forEach(v=>{
const node=nodes.find(n=>n.nodeId===v.id);
if(node&&node.blobUrl) v.blobUrl=node.blobUrl;
});
const w=window.open('videoplayer.html','_blank');
if(w){
let sent=false;
const send=()=>{if(sent)return;sent=true;w.postMessage({type:'loadProject',data:d},'*');};
setTimeout(send,1200);
}
toast('Opening preview...');
}
// ===== MODAL =====
function closeMod(id){document.getElementById(id).classList.remove('show');}
// ===== HELPERS =====
function fmt(s){if(!s||isNaN(s))return '0:00';const m=Math.floor(s/60),sc=Math.floor(s%60);return m+':'+(sc<10?'0':'')+sc;}
// ===== FULL RENDER =====
function fullRender(){renderNodes();setTimeout(()=>renderConns(),15);if(selNode)showTimeline(selNode);}
// ===== THEME =====
function toggleTheme(){
const isLight=document.body.classList.toggle('light');
document.getElementById('themeIcon').className=isLight?'bi bi-moon':'bi bi-sun';
localStorage.setItem('ivs-theme',isLight?'light':'dark');
}
(function initTheme(){
const saved=localStorage.getItem('ivs-theme');
if(saved==='light'){document.body.classList.add('light');document.getElementById('themeIcon').className='bi bi-moon';}
})();
fullRender();
</script>
</body>
</html>