Files
recap/public/index.html
T
Keysat 7d71150439 Fix SSE event type lost across reader chunks (blank screen post-process)
Symptom: after a long video finishes processing, the screen goes blank
even though the video and chunks save correctly to history (visible
after a refresh + library click).

Cause: the manual SSE parser in processUrl declared `let eventType = ""`
inside the `while (await reader.read())` loop, so the variable reset on
every chunk. The server emits each event as a single write of
`event: X\ndata: Y\n\n`, but for a long video the result payload (entries
+ chunks + logs) is tens of KB and gets split across reader chunks by
the browser. When the split landed between the `event: result\n` line
and the `data: ...` line, the event type was lost and `handleSSE("",
data)` matched no branch — the result event was silently dropped, and
state.chunks stayed at the empty array set during processUrl reset.

Fix: hoist eventType outside the loop so it persists across chunks, and
reset it after each dispatch (per the SSE spec, event type returns to
default after an event is fired).

Short videos with small result payloads fit in a single chunk and so
were unaffected — which is why the bug looked intermittent.
2026-05-08 11:04:50 -05:00

4162 lines
193 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Transcript Summarizer</title>
<link rel="icon" type="image/png" href="/assets/icon.png">
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0a0e1a;
min-height: 100vh;
color: #e2e8f0;
}
.container { max-width: 100%; margin: 0 auto; padding: 36px 24px; transition: margin-left 0.2s ease, max-width 0.2s ease; }
.container.has-results { padding: 16px 24px; display: flex; flex-direction: column; height: 100vh; overflow: hidden; }
/* Split-screen results layout */
.results-split {
display: flex; gap: 16px; flex: 1; min-height: 0;
}
.results-left {
flex: 0 0 58%; max-width: 58%; display: flex; flex-direction: column; gap: 10px;
}
.results-right {
flex: 1; display: flex; flex-direction: column; min-width: 0;
}
.results-right .stats-bar { flex-shrink: 0; }
.chunks-scroll {
flex: 1; overflow-y: auto; padding-right: 4px;
}
.chunks-scroll::-webkit-scrollbar { width: 6px; }
.chunks-scroll::-webkit-scrollbar-track { background: transparent; }
.chunks-scroll::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
.chunks-scroll::-webkit-scrollbar-thumb:hover { background: #334155; }
/* Video sticks to top in split mode */
.results-left .video-embed {
margin-bottom: 0; flex-shrink: 0;
}
.results-left .video-title {
font-size: 15px; font-weight: 600; color: #e2e8f0; line-height: 1.4;
padding: 0 2px;
}
.results-left .video-meta {
font-size: 12px; color: #475569; padding: 0 2px;
}
/* Minimized video player bar */
.video-mini-bar {
display: none; height: 44px; align-items: center; gap: 10px;
padding: 0 14px; background: #111827; border: 1px solid #1e293b;
border-radius: 10px; cursor: pointer; transition: all 0.2s;
}
.video-mini-bar:hover { border-color: #334155; background: #1e293b; }
.video-mini-bar .mini-title {
flex: 1; font-size: 13px; font-weight: 500; color: #e2e8f0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.video-mini-bar .mini-btn {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #334155;
background: none; color: #94a3b8; font-size: 14px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.video-mini-bar .mini-btn:hover { background: #334155; color: #e2e8f0; }
.results-left.minimized .video-embed { display: none; }
.results-left.minimized .video-title { display: none; }
.results-left.minimized .video-meta { display: none; }
.results-left.minimized .minimize-toggle { display: none; }
.results-left.minimized .video-mini-bar { display: flex; }
.results-left.minimized { flex: none !important; max-width: 100% !important; }
.minimize-toggle {
position: absolute; top: 8px; right: 8px; z-index: 5;
width: 30px; height: 30px; border-radius: 8px;
background: rgba(0,0,0,0.6); border: 1px solid rgba(255,255,255,0.1);
color: #fff; font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); transition: all 0.15s; opacity: 0;
}
.results-left:hover .minimize-toggle { opacity: 1; }
.minimize-toggle:hover { background: rgba(0,0,0,0.8); }
/* Mobile: video pinned to top, chunks scroll below */
@media (max-width: 900px) {
.results-split {
flex-direction: column; height: calc(100vh - 160px);
}
.results-left {
flex: none; max-width: 100%; position: sticky; top: 0;
z-index: 10; background: #0a0e1a; padding-bottom: 8px;
}
.results-left .video-title { font-size: 13px; }
.results-left .video-meta { font-size: 11px; }
.results-right { flex: 1; min-height: 0; }
.chunks-scroll { flex: 1; overflow-y: auto; }
.container.has-results { max-width: 100%; padding: 12px 12px; }
.container.has-results .top-bar { margin-bottom: 12px; }
.top-bar-input { margin: 0 6px; }
.top-bar-input .url-input { padding: 7px 10px; font-size: 12px; }
.top-bar-input .submit-btn { padding: 7px 12px; font-size: 12px; }
}
/* Landscape on mobile: video goes fullscreen-ish */
@media (max-width: 900px) and (orientation: landscape) {
.results-split { height: 100vh; }
.results-left {
position: fixed; inset: 0; z-index: 100;
background: #000; display: flex; align-items: center;
justify-content: center; padding: 0;
}
.results-left .video-embed {
width: 100%; height: 100%; border-radius: 0;
border: none; aspect-ratio: auto;
}
.results-left .video-title,
.results-left .video-meta { display: none; }
/* Show a small back button to exit fullscreen */
.landscape-back {
position: fixed; top: 12px; left: 12px; z-index: 101;
width: 36px; height: 36px; border-radius: 50%;
background: rgba(0,0,0,0.6); color: #fff; border: none;
font-size: 18px; cursor: pointer; display: flex;
align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.results-right { display: none; }
.container.has-results .top-bar,
.container.has-results .error-box,
.container.has-results .loading { display: none; }
}
/* Hide landscape-back in portrait / desktop */
@media (min-width: 901px), (orientation: portrait) {
.landscape-back { display: none !important; }
}
/* ── Mobile menu dropdown ────────────────────────────── */
.mobile-menu-btn { display: none; }
.mobile-menu-dropdown {
display: none; position: absolute; top: 100%; right: 0; z-index: 200;
background: #111827; border: 1px solid #1e293b; border-radius: 12px;
box-shadow: 0 12px 32px rgba(0,0,0,0.5); padding: 6px;
min-width: 180px; animation: slideUp 0.15s ease;
}
.mobile-menu-dropdown.open { display: block; }
.mobile-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%;
padding: 10px 14px; background: none; border: none; border-radius: 8px;
color: #94a3b8; font-size: 13px; font-weight: 500; cursor: pointer;
text-align: left; transition: all 0.15s; white-space: nowrap;
}
.mobile-menu-item:hover { background: #1e293b; color: #e2e8f0; }
.mobile-menu-item.active { color: #818cf8; }
.mobile-menu-item.danger { color: #f87171; }
.mobile-menu-item .menu-icon { width: 18px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.mobile-menu-item .menu-badge {
margin-left: auto; min-width: 18px; height: 18px; border-radius: 9px;
background: #818cf8; color: #fff; font-size: 10px; font-weight: 700;
display: flex; align-items: center; justify-content: center; padding: 0 5px;
}
.mobile-menu-item .status-dot {
width: 8px; height: 8px; border-radius: 50%; margin-left: auto; flex-shrink: 0;
}
.mobile-menu-sep { height: 1px; background: #1e293b; margin: 4px 8px; }
.mobile-menu-overlay {
display: none; position: fixed; inset: 0; z-index: 199;
}
.mobile-menu-overlay.open { display: block; }
/* ── Mobile top bar (≤600px) ─────────────────────────── */
@media (max-width: 600px) {
.container { padding: 12px 10px; }
.container.has-results { padding: 8px 8px; height: 100vh; height: 100dvh; }
.top-bar {
flex-wrap: nowrap; gap: 6px;
}
/* Row 1: library btn + input + mobile menu */
.top-left-actions { order: 1; flex-shrink: 0; }
.top-left-actions .icon-btn { width: 36px; height: 36px; }
.top-bar-input {
order: 2; flex: 1; min-width: 0; margin: 0; gap: 4px;
}
.top-bar-input .url-input { padding: 8px 10px; font-size: 12px; }
.top-bar-input .submit-btn { padding: 8px 12px; font-size: 11px; white-space: nowrap; }
/* Hide the desktop icon row, show hamburger */
.top-actions { display: none !important; }
.mobile-menu-btn {
display: flex; order: 3; flex-shrink: 0;
width: 36px; height: 36px; border-radius: 9px; border: 1px solid #1e293b;
background: #111827; color: #64748b; font-size: 20px;
cursor: pointer; align-items: center; justify-content: center;
transition: all 0.15s; position: relative;
}
.mobile-menu-btn:hover { background: #1e293b; color: #94a3b8; }
.mobile-menu-btn .dot {
position: absolute; top: 5px; right: 5px; width: 7px; height: 7px;
border-radius: 50%; background: #fbbf24;
}
/* Queue cards — more compact, allow title wrapping */
.queue-section { margin-top: 8px; }
.queue-item { padding: 8px 10px; gap: 6px; flex-wrap: wrap; }
.queue-item .queue-title { font-size: 12px; min-width: 60%; }
.queue-item .queue-from { font-size: 9px; }
.queue-item .queue-url { font-size: 11px; }
.queue-approve, .queue-reject { width: 28px; height: 28px; }
/* Queue header */
.queue-label { font-size: 10px; }
.queue-approve-all { font-size: 10px !important; padding: 3px 8px !important; }
/* Icon buttons smaller on mobile */
.icon-btn { width: 36px; height: 36px; border-radius: 9px; }
/* Stats bar wraps better */
.stats-bar { gap: 4px; flex-wrap: wrap; }
.stats { gap: 6px; font-size: 11px; flex-wrap: wrap; }
.expand-btn { padding: 5px 10px; font-size: 11px; }
/* Chunk headers more compact */
.chunk-header { padding: 8px 10px; gap: 6px; }
.chunk-header .chunk-time { font-size: 10px; }
.chunk-header .chunk-title { font-size: 12px; }
.chunk-header .chunk-summary { font-size: 11px; }
/* Transcript lines */
.transcript-line { padding: 3px 10px; font-size: 12px; }
.transcript-line .ts-badge { font-size: 10px; min-width: 36px; }
/* Settings modal full-width */
.settings-modal { max-width: 96vw; border-radius: 12px; }
/* Video embed smaller on mobile */
.video-embed { border-radius: 8px; }
/* Results split adjustments */
.results-split { height: calc(100vh - 70px); height: calc(100dvh - 70px); }
}
/* Top bar with title + settings gear */
.top-bar {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 12px; position: relative;
}
.top-left-actions {
display: flex; align-items: center; gap: 8px;
}
.top-bar-input {
flex: 1; display: flex; gap: 8px; max-width: 600px; margin: 0 12px;
}
.top-bar-input .url-input {
padding: 9px 14px; font-size: 13px; border-radius: 8px;
}
.top-bar-input .submit-btn {
padding: 9px 18px; font-size: 13px; border-radius: 8px;
}
/* Top-right icon buttons */
.top-actions { display: flex; gap: 8px; justify-content: flex-end; }
.icon-btn {
width: 40px; height: 40px; border-radius: 10px; border: 1px solid #1e293b;
background: #111827; color: #64748b; font-size: 20px; position: relative;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.2s;
}
.icon-btn:hover { background: #1e293b; color: #94a3b8; border-color: #334155; }
.icon-btn.active { background: #818cf8; color: #fff; border-color: transparent; }
.icon-btn .dot {
position: absolute; top: 6px; right: 6px; width: 8px; height: 8px;
border-radius: 50%; background: #fbbf24; display: none;
}
.icon-btn.needs-key .dot { display: block; }
.icon-btn.needs-attention .dot { display: block; background: #ef4444; }
.icon-btn .badge-count {
position: absolute; top: 4px; right: 4px; min-width: 16px; height: 16px;
border-radius: 8px; background: #818cf8; color: #fff; font-size: 9px;
font-weight: 700; display: flex; align-items: center; justify-content: center;
padding: 0 4px;
}
/* Settings modal overlay */
.settings-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 1000; display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); animation: fadeIn 0.15s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.settings-modal {
background: #111827; border: 1px solid #1e293b; border-radius: 16px;
width: 480px; max-width: 90vw; max-height: 85vh; overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,0.5); animation: slideUp 0.2s ease;
}
@keyframes slideUp { from { opacity:0; transform: translateY(12px); } to { opacity:1; transform: translateY(0); } }
.settings-modal-header {
display: flex; justify-content: space-between; align-items: center;
padding: 20px 24px; border-bottom: 1px solid #1e293b;
position: sticky; top: 0; background: #111827; z-index: 1; border-radius: 16px 16px 0 0;
}
.settings-modal-header h2 { font-size: 16px; font-weight: 700; color: #e2e8f0; }
.close-btn {
width: 32px; height: 32px; border-radius: 8px; border: 1px solid #1e293b;
background: none; color: #64748b; font-size: 18px; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.close-btn:hover { background: #1e293b; color: #e2e8f0; }
.settings-modal-body { padding: 20px 24px; }
/* Cards */
.card {
background: #111827;
border: 1px solid #1e293b;
border-radius: 14px;
margin-bottom: 14px;
overflow: hidden;
transition: border-color 0.2s;
}
.card:hover { border-color: #334155; }
.card-body { padding: 18px 22px; }
/* Model buttons */
.model-grid { display: flex; gap: 8px; flex-wrap: wrap; }
.model-btn {
padding: 8px 16px; font-size: 12px; font-weight: 600;
border: 1px solid #1e293b; border-radius: 8px; cursor: pointer;
transition: all 0.2s; background: #0f172a; color: #64748b;
}
.model-btn:hover { border-color: #334155; color: #94a3b8; }
.model-btn.active {
background: #818cf8;
color: #fff; border-color: transparent;
}
.model-note { font-size: 11px; color: #475569; margin-top: 8px; font-style: italic; }
/* Input row */
.input-row { display: flex; gap: 10px; }
.url-input {
flex: 1; padding: 14px 18px; font-size: 15px;
border: 1px solid #334155; border-radius: 10px;
outline: none; transition: all 0.2s;
background: #1e293b; color: #f1f5f9;
}
.url-input::placeholder { color: #64748b; }
.url-input:focus { border-color: #818cf8; box-shadow: 0 0 0 3px rgba(129,140,248,0.15); }
.submit-btn {
padding: 14px 30px; font-size: 15px; font-weight: 600;
background: #818cf8;
color: #fff; border: none; border-radius: 10px;
cursor: pointer; transition: all 0.2s; white-space: nowrap;
}
.submit-btn:disabled { background: #1e293b; color: #475569; cursor: not-allowed; }
.submit-btn:not(:disabled):hover { background: #a5b4fc; transform: translateY(-1px); }
/* API key */
.key-row { display: flex; gap: 8px; margin-bottom: 6px; }
.key-input {
flex: 1; padding: 10px 14px; font-size: 13px;
border: 1px solid #1e293b; border-radius: 8px; outline: none;
font-family: "SF Mono", Menlo, Consolas, monospace;
background: #0f172a; color: #94a3b8;
}
.key-input:focus { border-color: #818cf8; }
.key-toggle {
padding: 10px 14px; font-size: 12px; font-weight: 600;
background: #1e293b; color: #94a3b8; border: 1px solid #334155;
border-radius: 8px; cursor: pointer; white-space: nowrap;
}
.key-toggle:hover { background: #334155; }
.key-hint { font-size: 11px; color: #475569; margin-top: 4px; }
.field-label { font-size: 12px; font-weight: 600; color: #64748b; display: block; margin-bottom: 6px; margin-top: 16px; text-transform: uppercase; letter-spacing: 0.05em; }
.field-label:first-child { margin-top: 0; }
/* Status / Error */
.error-box {
background: rgba(239,68,68,0.08); border: 1px solid rgba(239,68,68,0.25); border-radius: 12px;
padding: 16px; margin-bottom: 20px; color: #f87171; font-size: 14px;
line-height: 1.5; white-space: pre-wrap;
}
.loading { text-align: center; padding: 48px 20px; }
.spinner {
width: 40px; height: 40px;
border: 3px solid #1e293b; border-top-color: #818cf8;
border-radius: 50%; animation: spin 0.8s linear infinite;
margin: 0 auto 16px;
}
.status-text { color: #94a3b8; font-size: 15px; font-weight: 500; animation: pulse 2s ease-in-out infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.5; } }
/* Pipeline steps */
.pipeline { display: flex; gap: 8px; align-items: center; justify-content: center; margin-bottom: 24px; flex-wrap: wrap; }
.step {
display: flex; align-items: center; gap: 6px;
padding: 8px 16px; border-radius: 8px; font-size: 13px; font-weight: 500;
background: #111827; border: 1px solid #1e293b; color: #475569; transition: all 0.3s;
}
.step.active { border-color: #818cf8; color: #a5b4fc; font-weight: 600; background: rgba(129,140,248,0.08); }
.step.done { border-color: #22c55e; color: #4ade80; background: rgba(34,197,94,0.08); }
.step-arrow { color: #334155; font-size: 16px; }
.step-icon { font-size: 16px; }
/* Video embed */
.video-embed {
border-radius: 14px; overflow: hidden; margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4); aspect-ratio: 16/9;
border: 1px solid #1e293b;
}
.video-embed iframe { display: block; width: 100%; height: 100%; border: 0; }
/* Stats bar */
.stats-bar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 8px; flex-wrap: wrap; gap: 8px;
}
.stats { display: flex; gap: 12px; font-size: 12px; color: #64748b; }
.stats strong { color: #e2e8f0; }
.expand-btn {
padding: 7px 16px; font-size: 12px; font-weight: 600;
background: #1e293b; color: #94a3b8; border: 1px solid #334155;
border-radius: 8px; cursor: pointer; transition: all 0.15s;
}
.expand-btn:hover { background: #334155; color: #e2e8f0; }
/* Chunk cards — compact */
.chunk {
background: #111827; border: 1px solid #1e293b; border-radius: 10px;
margin-bottom: 6px; overflow: hidden; transition: all 0.2s;
}
.chunk:hover { border-color: #334155; }
.chunk.expanded { border-color: #818cf8; background: #0f172a; }
.chunk-header {
width: 100%; padding: 10px 14px; background: none; border: none;
cursor: pointer; text-align: left; display: flex; gap: 10px;
align-items: flex-start; color: inherit;
}
.chunk-play-btn {
width: 26px; height: 26px; border-radius: 7px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
background: #1e293b; color: #64748b; border: none; cursor: pointer;
transition: all 0.15s; font-size: 12px; margin-top: 1px;
}
.chunk-play-btn:hover { background: #818cf8; color: #fff; }
.chunk.now-playing .chunk-play-btn { background: #22c55e; color: #fff; }
.chunk-info { flex: 1; min-width: 0; }
.chunk-title-row { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; }
.chunk-title {
font-size: 13px; font-weight: 650; color: #f1f5f9; line-height: 1.3;
}
.chunk-time {
font-size: 10px; color: #475569; font-weight: 500;
font-family: "SF Mono", Menlo, monospace; white-space: nowrap;
}
.chunk-summary {
margin: 3px 0 0; font-size: 12px; line-height: 1.5; color: #94a3b8;
}
.chunk-arrow {
font-size: 22px; color: #475569; transition: transform 0.2s; flex-shrink: 0;
padding: 4px 8px; border-radius: 6px; line-height: 1;
}
.chunk-arrow:hover { background: #1e293b; color: #818cf8; }
.chunk.expanded .chunk-arrow { transform: rotate(180deg); color: #818cf8; }
.chunk-body { max-height: 0; overflow: hidden; transition: max-height 0.4s ease; }
.chunk.expanded .chunk-body { max-height: 15000px; }
.chunk-body-inner {
padding: 0 14px 12px 52px; border-top: 1px solid #1e293b; padding-top: 12px;
}
.transcript-line {
display: flex; gap: 10px; align-items: flex-start; padding: 5px 8px;
cursor: pointer; border-radius: 6px; transition: background 0.15s;
border: none; background: none; width: 100%; text-align: left; color: inherit;
}
.transcript-line:hover { background: rgba(129,140,248,0.06); }
.ts-badge {
font-size: 11px; font-family: "SF Mono", Menlo, monospace; color: #818cf8;
min-width: 52px; padding-top: 2px;
font-weight: 500; white-space: nowrap; flex-shrink: 0;
}
.transcript-text { font-size: 13px; line-height: 1.55; color: #cbd5e1; }
/* Active transcript line (synced to playback) */
.transcript-line.active-line {
background: rgba(129,140,248,0.08); border-left: 2px solid #818cf8;
padding-left: 6px;
}
.transcript-line.active-line .ts-badge { color: #a5b4fc; font-weight: 600; }
.transcript-line.active-line .transcript-text { color: #f1f5f9; }
/* Clip collection buttons */
.clip-line-btn {
opacity: 0; font-size: 12px; cursor: pointer; flex-shrink: 0;
padding: 2px 4px; border-radius: 4px; transition: opacity 0.15s;
}
.transcript-line:hover .clip-line-btn { opacity: 0.7; }
.chunk-header:hover .chunk-clip-btn { opacity: 0.7; }
.clip-line-btn:hover { opacity: 1 !important; background: rgba(129,140,248,0.15); }
/* Clip collection badge */
.clip-badge {
display: inline-flex; align-items: center; gap: 4px;
padding: 4px 10px; border-radius: 6px; font-size: 11px; font-weight: 600;
background: rgba(129,140,248,0.1); color: #a5b4fc; cursor: pointer;
border: 1px solid rgba(129,140,248,0.2); transition: all 0.15s;
}
.clip-badge:hover { background: rgba(129,140,248,0.2); }
/* yt-dlp status */
.ytdlp-status {
margin-top: 16px; padding: 12px 14px; border-radius: 8px;
font-size: 13px; display: flex; align-items: center;
justify-content: space-between; gap: 10px; flex-wrap: wrap;
}
.ytdlp-ok { background: rgba(34,197,94,0.08); color: #4ade80; border: 1px solid rgba(34,197,94,0.15); }
.ytdlp-warn { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.15); }
.ytdlp-err { background: rgba(239,68,68,0.08); color: #f87171; border: 1px solid rgba(239,68,68,0.15); }
.update-btn {
padding: 6px 14px; font-size: 12px; font-weight: 600;
border: none; border-radius: 6px; cursor: pointer;
background: #f59e0b; color: #0f172a; transition: all 0.15s;
}
.update-btn:hover { background: #fbbf24; }
.update-btn:disabled { background: #334155; color: #64748b; cursor: not-allowed; }
/* Activity log entries (shared between drawer) */
.log-entry { display: flex; gap: 10px; }
.log-time { color: #334155; min-width: 52px; text-align: right; flex-shrink: 0; }
.log-msg { color: #64748b; }
.log-detail { color: #334155; }
.log-entry.error .log-msg { color: #f87171; }
.log-entry.done .log-msg { color: #4ade80; font-weight: 600; }
.log-entry.cost .log-msg { color: #fbbf24; }
/* Log drawer (slide-out panel) */
.log-drawer-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 1000; animation: fadeIn 0.15s ease;
}
.log-drawer {
position: fixed; top: 0; right: 0; bottom: 0; width: 440px; max-width: 92vw;
background: #0a0e17; border-left: 1px solid #1e293b;
z-index: 1001; display: flex; flex-direction: column;
animation: slideIn 0.2s ease; box-shadow: -8px 0 32px rgba(0,0,0,0.4);
}
@keyframes slideIn { from { transform: translateX(100%); } to { transform: translateX(0); } }
.log-drawer-header {
padding: 16px 20px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e293b; flex-shrink: 0;
}
.log-drawer-header h2 { font-size: 14px; font-weight: 700; color: #e2e8f0; }
.log-drawer-body {
flex: 1; overflow-y: auto; padding: 12px 16px;
font-family: "SF Mono", "Fira Code", Menlo, Consolas, monospace;
font-size: 12px; line-height: 1.8;
}
.log-drawer-body::-webkit-scrollbar { width: 6px; }
.log-drawer-body::-webkit-scrollbar-track { background: transparent; }
.log-drawer-body::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
.log-empty { color: #334155; text-align: center; padding: 40px 20px; font-size: 13px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
/* Glow effect on active elements */
.submit-btn:not(:disabled) { box-shadow: 0 4px 24px rgba(129,140,248,0.3); }
.chunk.expanded { box-shadow: 0 2px 16px rgba(129,140,248,0.06); }
/* Now-playing indicator */
.chunk.now-playing { border-color: #22c55e; }
/* History sidebar — persistent, pushes content */
.history-sidebar {
position: fixed; top: 0; left: 0; bottom: 0; width: 320px;
background: #0a0e17; border-right: 1px solid #1e293b;
z-index: 50; display: flex; flex-direction: column;
}
.history-sidebar.animate-in {
animation: slideInLeft 0.2s ease;
}
.history-item { transition: opacity 0.2s, transform 0.2s, max-height 0.3s; max-height: 80px; overflow: hidden; }
.history-item.removing { opacity: 0; transform: translateX(-20px); max-height: 0; margin: 0; padding: 0; }
@keyframes slideInLeft { from { transform: translateX(-100%); } to { transform: translateX(0); } }
/* Push main content when sidebar is open (desktop) */
body.history-open .container { margin-left: 320px; max-width: calc(100% - 320px); }
body.history-open .container.has-results { margin-left: 320px; max-width: calc(100% - 320px); }
/* No overlay on desktop */
.history-sidebar-overlay { display: none; }
/* Mobile: revert to overlay behavior */
@media (max-width: 900px) {
.history-sidebar { max-width: 85vw; z-index: 1001; box-shadow: 8px 0 32px rgba(0,0,0,0.4); }
.history-sidebar-overlay {
display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.4);
z-index: 1000; animation: fadeIn 0.15s ease;
}
body.history-open .container,
body.history-open .container.has-results { margin-left: 0; max-width: 100%; }
}
.history-header {
padding: 14px 16px; display: flex; justify-content: space-between;
align-items: center; border-bottom: 1px solid #1e293b; flex-shrink: 0;
}
.history-header h2 { font-size: 14px; font-weight: 700; color: #e2e8f0; }
.history-actions { display: flex; gap: 6px; }
.history-action-btn {
width: 30px; height: 30px; border-radius: 7px; border: 1px solid #1e293b;
background: none; color: #64748b; cursor: pointer;
display: flex; align-items: center; justify-content: center; transition: all 0.15s;
}
.history-action-btn:hover { background: #1e293b; color: #94a3b8; }
.history-list {
flex: 1; overflow-y: auto; padding: 8px;
}
.history-list::-webkit-scrollbar { width: 6px; }
.history-list::-webkit-scrollbar-track { background: transparent; }
.history-list::-webkit-scrollbar-thumb { background: #1e293b; border-radius: 3px; }
/* Folders */
.history-folder { margin-bottom: 4px; }
.history-folder-header {
display: flex; align-items: center; gap: 8px; padding: 8px 10px;
border-radius: 8px; cursor: pointer; border: none; background: none;
width: 100%; text-align: left; color: #94a3b8; font-size: 12px;
font-weight: 600; transition: background 0.15s;
}
.history-folder-header:hover { background: rgba(255,255,255,0.03); }
.folder-arrow { font-size: 10px; color: #475569; transition: transform 0.2s; width: 12px; text-align: center; }
.folder-arrow.open { transform: rotate(90deg); }
.folder-icon { font-size: 14px; }
.folder-name { flex: 1; }
.folder-count { color: #334155; font-size: 11px; font-weight: 500; }
.folder-actions { display: flex; gap: 2px; opacity: 0; transition: opacity 0.15s; }
.history-folder-header:hover .folder-actions { opacity: 1; }
.folder-action {
width: 22px; height: 22px; border-radius: 4px; border: none;
background: none; color: #475569; cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 11px;
}
.folder-action:hover { background: rgba(255,255,255,0.06); color: #94a3b8; }
.folder-action.danger:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.folder-items { padding-left: 12px; }
.folder-items.collapsed { display: none; }
.folder-drop-zone {
height: 2px; margin: 2px 0; border-radius: 1px; transition: all 0.15s;
}
.folder-drop-zone.drag-over { height: 4px; background: #818cf8; margin: 4px 0; }
/* History items */
.history-item {
display: flex; gap: 10px; align-items: center;
padding: 8px 10px; border-radius: 8px; cursor: grab;
transition: background 0.15s; border: 1px solid transparent; background: none;
width: 100%; text-align: left; color: inherit; font-size: 13px;
}
.history-item:hover { background: rgba(255,255,255,0.04); }
.history-item.active { background: rgba(129,140,248,0.1); border-color: rgba(129,140,248,0.2); }
.history-item.dragging { opacity: 0.3; }
.history-item.drop-above { box-shadow: 0 -2px 0 0 #818cf8; }
.history-item.drop-below { box-shadow: 0 2px 0 0 #818cf8; }
.history-thumb {
width: 44px; height: 33px; border-radius: 4px; flex-shrink: 0;
background: #1e293b; overflow: hidden;
}
.history-thumb img { width: 100%; height: 100%; object-fit: cover; }
.history-info { flex: 1; min-width: 0; }
.history-title {
font-size: 12px; font-weight: 600; color: #e2e8f0; line-height: 1.3;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.history-meta { font-size: 10px; color: #475569; margin-top: 1px; }
.history-delete {
width: 24px; height: 24px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.history-item:hover .history-delete { opacity: 1; }
.history-delete:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.history-action-small {
width: 24px; height: 24px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.history-item:hover .history-action-small { opacity: 1; }
.history-action-small:hover { background: rgba(129,140,248,0.15); color: #818cf8; }
.history-section-label {
font-size: 10px; font-weight: 700; color: #334155; text-transform: uppercase;
letter-spacing: 0.06em; padding: 12px 10px 4px; }
.history-empty {
color: #334155; text-align: center; padding: 40px 20px; font-size: 13px;
}
/* Folder name edit */
.folder-name-input {
background: #0f172a; border: 1px solid #818cf8; border-radius: 4px;
color: #e2e8f0; font-size: 12px; font-weight: 600; padding: 2px 6px;
outline: none; width: 100%;
}
/* Session title edit */
.history-title-input {
background: #0f172a; border: 1px solid #818cf8; border-radius: 3px;
color: #e2e8f0; font-size: 12px; font-weight: 600; padding: 1px 4px;
outline: none; width: 100%;
}
/* Queue */
.queue-section { margin-top: 10px; }
.queue-item {
display: flex; align-items: center; gap: 8px; padding: 8px 14px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
margin-bottom: 6px; font-size: 13px; color: #94a3b8; transition: all 0.15s;
}
.queue-item.processing { border-color: #818cf8; background: rgba(129,140,248,0.06); }
.queue-item .queue-pos {
width: 22px; height: 22px; border-radius: 6px; background: #1e293b;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 700; color: #475569; flex-shrink: 0;
}
.queue-item.processing .queue-pos { background: #818cf8; color: #fff; }
.queue-item .queue-url {
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; font-family: "SF Mono", Menlo, monospace; font-size: 12px;
}
.queue-item .queue-status { font-size: 11px; color: #475569; flex-shrink: 0; }
.queue-item.processing .queue-status { color: #a5b4fc; }
.queue-remove {
width: 22px; height: 22px; border-radius: 5px; border: none;
background: none; color: #475569; cursor: pointer; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; opacity: 0;
}
.queue-item:hover .queue-remove { opacity: 1; }
.queue-item.processing .queue-remove { display: none; }
.queue-remove:hover { background: rgba(239,68,68,0.15); color: #f87171; }
.queue-label { font-size: 11px; color: #475569; font-weight: 600; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
.queue-item .queue-from {
font-size: 10px; color: #818cf8; background: rgba(129,140,248,0.1);
padding: 1px 6px; border-radius: 4px; flex-shrink: 0; white-space: nowrap;
}
.queue-item.pending-approval { border-color: #334155; border-style: dashed; background: rgba(129,140,248,0.03); }
.queue-item .queue-title {
flex: 1; min-width: 0; white-space: nowrap; overflow: hidden;
text-overflow: ellipsis; font-size: 12px; color: #cbd5e1;
}
.queue-item .queue-actions { display: flex; gap: 4px; flex-shrink: 0; }
.queue-approve, .queue-reject {
width: 26px; height: 26px; border-radius: 6px; border: none;
cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: all 0.15s; font-size: 14px;
}
.queue-approve { background: rgba(34,197,94,0.12); color: #4ade80; }
.queue-approve:hover { background: rgba(34,197,94,0.25); color: #86efac; }
.queue-reject { background: rgba(239,68,68,0.1); color: #f87171; }
.queue-reject:hover { background: rgba(239,68,68,0.2); color: #fca5a5; }
.queue-approve-all {
font-size: 10px; color: #818cf8; background: rgba(129,140,248,0.1);
border: none; border-radius: 4px; padding: 2px 8px; cursor: pointer;
transition: all 0.15s; font-weight: 600; letter-spacing: 0.02em;
}
.queue-approve-all:hover { background: rgba(129,140,248,0.2); color: #a5b4fc; }
/* Toast notifications */
.toast-container {
position: fixed; top: 16px; right: 16px; z-index: 9999;
display: flex; flex-direction: column; gap: 8px; pointer-events: none;
}
.toast {
background: #1e293b; border: 1px solid #334155; border-radius: 10px;
padding: 10px 16px; color: #e2e8f0; font-size: 13px;
display: flex; align-items: center; gap: 8px;
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
animation: toastIn 0.25s ease;
pointer-events: auto; max-width: 340px;
}
.toast.fade-out { animation: toastOut 0.3s ease forwards; }
.toast-icon { font-size: 16px; flex-shrink: 0; }
.toast-msg { flex: 1; line-height: 1.3; }
@keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: translateX(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateX(20px); } }
/* Subscriptions */
.sub-section { margin-top: 20px; padding-top: 16px; border-top: 1px solid #1e293b; }
.sub-add-row { display: flex; gap: 8px; margin-bottom: 10px; }
.sub-add-row input {
flex: 1; padding: 9px 12px; font-size: 12px;
border: 1px solid #1e293b; border-radius: 8px; outline: none;
background: #0f172a; color: #e2e8f0;
}
.sub-add-row input:focus { border-color: #818cf8; }
.sub-add-row input::placeholder { color: #475569; }
.sub-add-btn {
padding: 9px 16px; font-size: 12px; font-weight: 600;
background: #818cf8;
color: #fff; border: none; border-radius: 8px; cursor: pointer;
white-space: nowrap; transition: all 0.15s;
}
.sub-add-btn:disabled { background: #1e293b; color: #475569; cursor: not-allowed; }
.sub-add-btn:not(:disabled):hover { background: #a5b4fc; }
.sub-item {
display: flex; align-items: center; gap: 10px; padding: 10px 12px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 8px;
margin-bottom: 6px; transition: all 0.15s;
}
.sub-item.paused { opacity: 0.5; }
.sub-icon { font-size: 16px; flex-shrink: 0; }
.sub-info { flex: 1; min-width: 0; }
.sub-name { font-size: 13px; font-weight: 600; color: #e2e8f0; }
.sub-meta { font-size: 10px; color: #475569; margin-top: 1px; position: relative; }
.sub-actions { display: flex; gap: 4px; }
.sub-action {
width: 28px; height: 28px; border-radius: 6px; border: 1px solid #1e293b;
background: none; color: #64748b; cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.15s; font-size: 13px;
}
.sub-action:hover { background: #1e293b; color: #94a3b8; }
.sub-action.danger:hover { background: rgba(239,68,68,0.15); color: #f87171; border-color: rgba(239,68,68,0.3); }
.sub-empty { font-size: 12px; color: #334155; text-align: center; padding: 16px; }
/* Server status indicator */
.server-status {
display: inline-flex; align-items: center; gap: 5px;
font-size: 10px; font-weight: 600; padding: 3px 10px;
border-radius: 6px; letter-spacing: 0.03em;
cursor: default; transition: all 0.3s;
}
.server-status .status-dot {
width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0;
transition: background 0.3s;
}
.server-status.connected {
background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2);
}
.server-status.connected .status-dot { background: #4ade80; }
.server-status.sleeping {
background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.2);
}
.server-status.sleeping .status-dot { background: #fbbf24; }
.server-status.disconnected {
background: rgba(239,68,68,0.1); color: #f87171; border: 1px solid rgba(239,68,68,0.2);
}
.server-status.disconnected .status-dot { background: #f87171; }
@keyframes pulse-dot { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
/* Skeleton loading cards */
.skeleton-chunk {
background: #111827; border: 1px solid #1e293b; border-radius: 10px;
margin-bottom: 6px; padding: 12px 14px; overflow: hidden;
}
.skeleton-line {
height: 12px; border-radius: 4px; background: #1e293b; margin-bottom: 8px;
position: relative; overflow: hidden;
}
.skeleton-line::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.03), transparent);
animation: shimmer 1.8s ease-in-out infinite;
}
@keyframes shimmer { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.skeleton-line.title { width: 70%; height: 14px; }
.skeleton-line.subtitle { width: 90%; }
.skeleton-line.short { width: 40%; }
.loading-status-bar {
display: flex; align-items: center; gap: 10px; margin-bottom: 12px;
padding: 10px 14px; background: #111827; border: 1px solid #1e293b;
border-radius: 10px;
}
.loading-status-bar .spinner-sm {
width: 18px; height: 18px; border: 2px solid #1e293b; border-top-color: #818cf8;
border-radius: 50%; animation: spin 0.8s linear infinite; flex-shrink: 0;
}
.loading-status-bar .status-msg { font-size: 12px; color: #94a3b8; font-weight: 500; }
.server-status.sleeping .status-dot { animation: pulse-dot 2s ease-in-out infinite; }
/* ── Keysat activation + license UI ───────────────────────────── */
.activation-screen {
position: fixed; inset: 0; z-index: 9999;
background: radial-gradient(ellipse at top, #1e293b 0%, #0a0e1a 60%);
display: flex; align-items: center; justify-content: center;
padding: 24px; overflow-y: auto;
}
.activation-card {
width: 100%; max-width: 480px;
background: #0f172a; border: 1px solid #1e293b; border-radius: 14px;
padding: 32px 28px; box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.activation-card h1 { font-size: 22px; font-weight: 700; color: #e2e8f0; margin-bottom: 6px; }
.activation-card .activation-sub { font-size: 13px; color: #94a3b8; line-height: 1.5; margin-bottom: 22px; }
.activation-card .activation-label { display: block; font-size: 11px; font-weight: 600; color: #94a3b8; text-transform: uppercase; letter-spacing: 0.4px; margin-bottom: 6px; }
.activation-card textarea.activation-key {
width: 100%; min-height: 88px; padding: 12px;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: 11.5px;
background: #020617; color: #e2e8f0; border: 1px solid #334155; border-radius: 8px;
outline: none; resize: vertical; line-height: 1.45;
}
.activation-card textarea.activation-key:focus { border-color: #6366f1; }
.activation-card .activation-error {
margin-top: 10px; padding: 10px 12px;
background: rgba(220, 38, 38, 0.1); border: 1px solid rgba(220, 38, 38, 0.4);
border-radius: 8px; color: #fca5a5; font-size: 12px; line-height: 1.4;
}
.activation-card .activation-actions {
display: flex; gap: 10px; margin-top: 16px; align-items: center;
}
.activation-card .activation-btn {
flex: 1; padding: 11px 18px; font-size: 13px; font-weight: 600;
background: #6366f1; color: #fff; border: none; border-radius: 8px; cursor: pointer;
transition: background 0.15s;
}
.activation-card .activation-btn:hover:not(:disabled) { background: #4f46e5; }
.activation-card .activation-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.activation-card .activation-link {
font-size: 12px; color: #818cf8; text-decoration: none; padding: 8px 4px;
}
.activation-card .activation-link:hover { color: #a5b4fc; text-decoration: underline; }
.activation-card .activation-meta {
margin-top: 24px; padding-top: 18px; border-top: 1px solid #1e293b;
font-size: 11px; color: #64748b; line-height: 1.5;
}
/* In-settings license block + Pro upsell tiles */
.license-block {
padding: 14px; border: 1px solid #334155; border-radius: 10px;
background: rgba(99, 102, 241, 0.06); margin-bottom: 14px;
}
.license-block .lic-row { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.license-block .lic-tier {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
padding: 3px 8px; border-radius: 999px;
}
.license-block .lic-tier.core { background: rgba(99, 102, 241, 0.15); color: #a5b4fc; border: 1px solid rgba(99, 102, 241, 0.4); }
.license-block .lic-tier.pro { background: rgba(168, 85, 247, 0.15); color: #d8b4fe; border: 1px solid rgba(168, 85, 247, 0.4); }
.license-block .lic-tier.unlicensed { background: rgba(148, 163, 184, 0.1); color: #94a3b8; border: 1px solid #334155; }
.license-block .lic-meta { font-size: 11px; color: #64748b; margin-top: 8px; line-height: 1.5; }
.license-block .lic-meta .lic-id { font-family: ui-monospace, "SF Mono", Menlo, monospace; color: #94a3b8; }
.license-block .lic-actions { display: flex; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.license-block .lic-btn {
font-size: 11px; font-weight: 600; padding: 6px 12px; border-radius: 6px;
border: 1px solid #334155; background: #1e293b; color: #94a3b8; cursor: pointer;
text-decoration: none; display: inline-flex; align-items: center; gap: 4px;
}
.license-block .lic-btn:hover { background: #334155; color: #e2e8f0; }
.license-block .lic-btn.danger { color: #fca5a5; border-color: rgba(220, 38, 38, 0.4); }
.license-block .lic-btn.danger:hover { background: rgba(220, 38, 38, 0.1); }
.pro-upsell {
padding: 14px; border: 1px dashed #334155; border-radius: 10px;
background: rgba(168, 85, 247, 0.05); margin: 8px 0;
}
.pro-upsell .pro-title { font-size: 13px; font-weight: 700; color: #d8b4fe; display: flex; align-items: center; gap: 6px; }
.pro-upsell .pro-desc { font-size: 11.5px; color: #94a3b8; margin-top: 6px; line-height: 1.5; }
.pro-upsell .pro-cta {
display: inline-block; margin-top: 10px;
padding: 6px 12px; font-size: 11px; font-weight: 600;
background: #a855f7; color: #fff; border-radius: 6px; text-decoration: none;
}
.pro-upsell .pro-cta:hover { background: #9333ea; }
</style>
</head>
<body class="history-open">
<div class="container" id="app"></div>
<div class="toast-container" id="toast-container"></div>
<!-- YouTube IFrame API -->
<script src="https://unpkg.com/jspdf@2.5.1/dist/jspdf.umd.min.js"></script>
<script src="https://www.youtube.com/iframe_api"></script>
<script>
// ── YouTube Player API ──────────────────────────────────────────────────
let ytPlayer = null;
let ytReady = false;
let ytCurrentVideoId = null;
let ytSavedTime = 0;
let ytWasPlaying = false;
function onYouTubeIframeAPIReady() {
ytReady = true;
}
function initPlayer(videoId) {
// If same video, just restore — don't recreate
if (ytCurrentVideoId === videoId && document.getElementById("yt-player")) {
// Player div was recreated by render(), need to rebuild but preserve time
const savedTime = ytSavedTime;
const wasPlaying = ytWasPlaying;
if (ytPlayer) { try { ytPlayer.destroy(); } catch {} ytPlayer = null; }
ytPlayer = new YT.Player("yt-player", {
videoId: videoId,
playerVars: { autoplay: 0, modestbranding: 1, rel: 0 },
events: {
onReady: () => {
if (savedTime > 0) {
ytPlayer.seekTo(savedTime, true);
}
// Only resume playback if the video was actually playing before render
if (wasPlaying) {
ytPlayer.playVideo();
} else {
// Explicitly pause to counteract any iframe auto-play behavior
ytPlayer.pauseVideo();
setTimeout(() => { try { ytPlayer.pauseVideo(); } catch {} }, 200);
setTimeout(() => { try { ytPlayer.pauseVideo(); } catch {} }, 500);
}
},
onStateChange: (e) => onPlayerStateChange(e),
},
});
return;
}
// New video
ytCurrentVideoId = videoId;
ytSavedTime = 0;
ytWasPlaying = false;
if (ytPlayer) { try { ytPlayer.destroy(); } catch {} ytPlayer = null; }
ytPlayer = new YT.Player("yt-player", {
videoId: videoId,
playerVars: { autoplay: 0, modestbranding: 1, rel: 0 },
events: {
onReady: () => {},
onStateChange: (e) => onPlayerStateChange(e),
},
});
}
// Save player state before render wipes the DOM
let savedLastSeekTarget = null;
function savePlayerState() {
if (ytPlayer && ytPlayer.getCurrentTime) {
try {
ytSavedTime = ytPlayer.getCurrentTime();
ytWasPlaying = ytPlayer.getPlayerState() === YT.PlayerState.PLAYING;
} catch {}
}
// Preserve lastSeekTarget across renders
savedLastSeekTarget = lastSeekTarget;
// Pause sync during render (DOM will be wiped)
lastHighlightedLine = null;
}
let lastSeekTarget = null;
function seekTo(seconds) {
// Auto-expand video if minimized when user clicks a transcript segment
if (state.videoMinimized) {
state.videoMinimized = false;
render();
// Wait for player to reinit, then seek
setTimeout(() => seekTo(seconds), 200);
return;
}
const sameSegment = (lastSeekTarget === seconds);
if (state.currentType === "podcast") {
const audio = document.getElementById("podcast-audio");
if (audio) {
if (sameSegment) {
// Toggle pause/resume without re-seeking
if (!audio.paused) {
audio.pause();
} else {
audio.play().catch(() => {});
startPodcastSync();
}
} else {
// New segment — seek and play
audio.currentTime = seconds;
audio.play().catch(() => {});
startPodcastSync();
lastSeekTarget = seconds;
}
}
return;
}
if (ytPlayer && ytPlayer.seekTo) {
if (sameSegment) {
// Toggle pause/resume without re-seeking
if (ytPlayer.getPlayerState() === YT.PlayerState.PLAYING) {
ytPlayer.pauseVideo();
} else {
ytPlayer.playVideo();
}
} else {
// New segment — seek and play
ytPlayer.seekTo(seconds, true);
ytPlayer.playVideo();
lastSeekTarget = seconds;
}
}
}
function toggleVideoMinimize() {
state.videoMinimized = !state.videoMinimized;
render();
}
function togglePlayPause() {
if (state.currentType === "podcast") {
const audio = document.getElementById("podcast-audio");
if (audio) { audio.paused ? audio.play().catch(() => {}) : audio.pause(); }
return;
}
if (ytPlayer && ytPlayer.getPlayerState) {
if (ytPlayer.getPlayerState() === YT.PlayerState.PLAYING) {
ytPlayer.pauseVideo();
} else {
ytPlayer.playVideo();
}
}
}
function onPlayerStateChange(event) {
if (event.data === YT.PlayerState.PLAYING) {
startTranscriptSync();
} else {
stopTranscriptSync();
}
}
// ── Transcript highlight sync ────────────────────────────────────────────
let syncInterval = null;
let lastHighlightedLine = null;
function startTranscriptSync() {
if (syncInterval) return;
syncInterval = setInterval(syncTranscriptHighlight, 250);
}
function stopTranscriptSync() {
if (syncInterval) { clearInterval(syncInterval); syncInterval = null; }
clearTranscriptHighlight();
}
function syncTranscriptHighlight() {
if (!ytPlayer || !ytPlayer.getCurrentTime || !ytPlayer.getPlayerState) return;
try {
const playerState = ytPlayer.getPlayerState();
if (playerState !== YT.PlayerState.PLAYING) return;
const currentTime = ytPlayer.getCurrentTime();
// Find which transcript line matches
const lines = document.querySelectorAll(".transcript-line[data-offset]");
let activeLine = null;
for (const line of lines) {
const offset = parseFloat(line.dataset.offset);
if (offset <= currentTime) activeLine = line;
else break;
}
if (activeLine === lastHighlightedLine) return;
// Remove old highlight
if (lastHighlightedLine) lastHighlightedLine.classList.remove("active-line");
// Add new highlight
if (activeLine) {
activeLine.classList.add("active-line");
// Scroll the line into view within the chunk body
activeLine.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
lastHighlightedLine = activeLine;
// Also highlight the parent chunk in the now-playing style
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
if (activeLine) {
const chunk = activeLine.closest(".chunk");
if (chunk) chunk.classList.add("now-playing");
}
} catch {}
}
function clearTranscriptHighlight() {
if (lastHighlightedLine) {
lastHighlightedLine.classList.remove("active-line");
lastHighlightedLine = null;
}
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
}
// ── Podcast audio player sync ───────────────────────────────────────────
let podcastSyncInterval = null;
function startPodcastSync() {
if (podcastSyncInterval) return;
podcastSyncInterval = setInterval(syncPodcastHighlight, 250);
}
function stopPodcastSync() {
if (podcastSyncInterval) { clearInterval(podcastSyncInterval); podcastSyncInterval = null; }
clearTranscriptHighlight();
}
function syncPodcastHighlight() {
const audio = document.getElementById("podcast-audio");
if (!audio || audio.paused) return;
const currentTime = audio.currentTime;
const lines = document.querySelectorAll(".transcript-line[data-offset]");
let activeLine = null;
for (const line of lines) {
const offset = parseFloat(line.dataset.offset);
if (offset <= currentTime) activeLine = line;
else break;
}
if (activeLine === lastHighlightedLine) return;
if (lastHighlightedLine) lastHighlightedLine.classList.remove("active-line");
if (activeLine) {
activeLine.classList.add("active-line");
activeLine.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
lastHighlightedLine = activeLine;
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
if (activeLine) {
const chunk = activeLine.closest(".chunk");
if (chunk) chunk.classList.add("now-playing");
}
}
function initPodcastPlayer() {
const audio = document.getElementById("podcast-audio");
if (!audio) return;
audio.addEventListener("play", startPodcastSync);
audio.addEventListener("pause", stopPodcastSync);
audio.addEventListener("ended", stopPodcastSync);
}
// ── State ────────────────────────────────────────────────────────────────
const state = {
url: "",
apiKey: localStorage.getItem("yt-summarizer-gemini-key") || "",
hasServerKey: false, // will be set by health check
lanMode: null, // null = unknown, true = home, false = traveling
serverStatus: "connecting", // "connected" | "sleeping" | "disconnected" | "connecting"
model: "gemini-3.1-pro-preview",
showKey: false,
settingsOpen: false,
loading: false,
currentStep: 0,
status: "",
error: null,
videoId: null,
videoTitle: "",
chunks: [],
expandAll: false,
expandedChunks: new Set(),
logs: [],
logOpen: false,
// history
historyOpen: true,
historySessions: {}, // id → session summary
historyMeta: { folders: [], uncategorized: [] },
historyLoaded: false,
collapsedFolders: new Set(),
draggingId: null,
editingFolder: null,
editingSessionTitle: null,
ytdlpVersion: null,
ytdlpLatest: null,
ytdlpUpdateAvailable: false,
ytdlpUpdating: false,
// queue
queue: [], // { id, url, status: 'queued'|'processing'|'done'|'error', error: null }
queueProcessing: false,
queueCollapsed: false,
// subscriptions
subscriptions: [],
subsLoaded: false,
addingSubUrl: "",
addingSubSince: "", // optional cutoff date (YYYY-MM-DD)
addingSubAuto: false, // auto-process new videos (skip queue approval)
addingSubLoading: false,
subCheckLog: [], // persisted log from last subscription check
currentType: "youtube", // "youtube" or "podcast"
videoMinimized: false,
currentSessionId: null,
// clip collection for selective sharing
clipCollection: [], // { sessionId, chunkIndex, entryIndex (optional) }
clipPanelOpen: false,
// mobile menu
mobileMenuOpen: false,
// cookie status
cookieMethod: "none",
cookieFileAgeDays: null,
cookieFileExpiring: false,
// license (Keysat)
license: {
loaded: false,
state: "loading", // 'loading' | 'licensed' | 'unlicensed' | 'invalid'
reason: null,
licenseId: null,
entitlements: [],
expiresAt: null,
isTrial: false,
productSlug: "youtube-summarizer",
keysatBaseUrl: "",
},
licenseActivating: false,
licenseActivationError: null,
licenseActivationKey: "",
};
const MODELS = ["gemini-3.1-pro-preview", "gemini-3-pro-preview", "gemini-3-flash-preview"];
// Auto-detect server origin so it works from other devices on the network
const API_BASE = window.location.origin;
function formatTime(seconds) {
const s = Math.floor(seconds);
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const sec = s % 60;
if (h > 0) return h + ":" + String(m).padStart(2, "0") + ":" + String(sec).padStart(2, "0");
return m + ":" + String(sec).padStart(2, "0");
}
// ── Close / Clear ─────────────────────────────────────────────────────────
function closeVideo() {
stopPodcastSync();
state.videoId = null;
state.videoTitle = "";
state.url = "";
state.chunks = [];
state.error = null;
state.expandedChunks = new Set();
state.expandAll = false;
state.loading = false;
state.currentStep = 0;
state.currentType = "youtube";
state.status = "";
state.videoMinimized = false;
state.currentSessionId = null;
ytCurrentVideoId = null;
render();
}
// ── Process ──────────────────────────────────────────────────────────────
async function handleSubmit() {
const hasKey = state.apiKey.trim() || state.hasServerKey;
if (!state.url.trim() || !hasKey) return;
const url = state.url.trim();
state.url = "";
// If already processing, add to queue
if (state.loading) {
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
render();
return;
}
// Start processing immediately
await processUrl(url);
}
function extractVideoId(url) {
const patterns = [
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
/^([a-zA-Z0-9_-]{11})$/,
];
for (const p of patterns) {
const m = url.match(p);
if (m) return m[1];
}
return null;
}
function isChannelUrl(url) {
if (!url) return false;
const u = url.trim().toLowerCase();
// Channel pages: /@handle, /c/name, /channel/ID, /user/name
if (/youtube\.com\/@[^/]+/i.test(u)) return true;
if (/youtube\.com\/(c|channel|user)\/[^/]+/i.test(u)) return true;
// Playlist page
if (/youtube\.com\/playlist\?/i.test(u)) return true;
// Explicit /videos or /streams tab
if (/youtube\.com\/.*\/(videos|streams|shorts)/i.test(u)) return true;
return false;
}
function isPodcastUrl(url) {
if (!url) return false;
const u = url.trim().toLowerCase();
if (u.includes("/feed") || u.includes("/rss") || u.includes("feeds.") || u.includes(".xml")) return true;
if (u.includes("anchor.fm") || u.includes("feeds.buzzsprout") || u.includes("feeds.simplecast")) return true;
if (u.includes("feeds.megaphone") || u.includes("feeds.transistor") || u.includes("feeds.libsyn")) return true;
if (u.includes("feeds.podcastmirror") || u.includes("feeds.acast") || u.includes("feeds.fireside")) return true;
if (u.includes("rss.art19") || u.includes("podbean.com/feed")) return true;
return false;
}
function isSubscribeUrl(url) {
return isChannelUrl(url) || isPodcastUrl(url);
}
async function processUrl(url, opts = {}) {
// Extract video ID immediately so we can show the embed while processing
const earlyVideoId = opts.type !== "podcast" ? extractVideoId(url) : null;
if (earlyVideoId) {
state.videoId = earlyVideoId;
state.videoTitle = "";
ytCurrentVideoId = null;
}
state.currentType = opts.type || "youtube";
state.loading = true;
state.error = null;
state.chunks = [];
state.currentStep = 1;
state.status = "Starting...";
state.settingsOpen = false;
state.expandedChunks = new Set();
// Accumulate logs across queue items — add separator for 2nd+ items
const title = opts.title || url;
if (state.logs.length > 0) {
state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
} else {
state.logs.push({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
}
render();
try {
const body = {
url,
apiKey: state.apiKey.trim() || "USE_SERVER_KEY",
model: state.model,
};
if (opts.type) body.type = opts.type;
if (opts.title) body.title = opts.title;
if (opts.uploadDate) body.uploadDate = opts.uploadDate;
if (opts.episodeId) body.episodeId = opts.episodeId;
const res = await fetch(`${API_BASE}/api/process`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok && res.headers.get("content-type")?.includes("application/json")) {
const err = await res.json();
throw new Error(err.error || `Server error: ${res.status}`);
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
// Persist eventType across reader chunks. SSE events are
// `event: X\ndata: Y\n\n`, but a single TCP/fetch chunk can split
// between those two lines — when that happens, we'd lose the event
// type and silently drop the event (e.g. the final `result` for a
// long video, where the payload is tens of KB). Reset only after
// dispatch, per the SSE spec.
let eventType = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event: ")) {
eventType = line.slice(7);
} else if (line.startsWith("data: ")) {
const data = JSON.parse(line.slice(6));
handleSSE(eventType, data);
eventType = "";
}
}
}
} catch (err) {
state.error = err.message;
} finally {
state.loading = false;
state.currentStep = 0;
render();
// Process next item in queue
processQueue();
}
}
async function processQueue() {
if (state.loading) return;
const next = state.queue.find(q => q.status === "queued");
if (!next) return;
next.status = "processing";
render();
await processUrl(next.url, {
type: next.type || "youtube",
title: next.title || "",
uploadDate: next.uploadDate || "",
episodeId: next.videoId || "",
});
// Mark as done (or error) after processUrl finishes
next.status = state.error ? "error" : "done";
next.error = state.error || null;
// Remove completed items from queue
state.queue = state.queue.filter(q => q.status === "queued");
render();
}
function removeFromQueue(id) {
state.queue = state.queue.filter(q => q.id !== id);
render();
}
async function approveQueueItem(id) {
const item = state.queue.find(q => q.id === id);
if (!item) return;
item.status = "queued";
// Remove from server auto-queue now that it's approved
fetch(`${API_BASE}/api/auto-queue/${id}`, { method: "DELETE" }).catch(() => {});
render();
// Start processing if not already running
if (!state.loading) processQueue();
}
function approveAllQueue() {
for (const item of state.queue) {
if (item.status === "pending_approval") {
item.status = "queued";
fetch(`${API_BASE}/api/auto-queue/${item.id}`, { method: "DELETE" }).catch(() => {});
}
}
render();
if (!state.loading) processQueue();
}
async function rejectQueueItem(id) {
const item = state.queue.find(q => q.id === id);
if (!item) return;
// Add to skip list on server
fetch(`${API_BASE}/api/auto-queue/${id}/skip`, { method: "POST" }).catch(() => {});
state.queue = state.queue.filter(q => q.id !== id);
render();
}
function handleSSE(event, data) {
if (event === "status") {
state.currentStep = data.step;
state.status = data.message;
// Surgical update — don't full-render, just update status text and step dots
const statusMsg = document.querySelector(".status-msg");
if (statusMsg) statusMsg.textContent = data.message;
// Update step dots (the 3 small circles)
document.querySelectorAll("[title='Download'], [title='Transcribe'], [title='Analyze']").forEach(dot => {
const stepNum = dot.title === "Download" ? 1 : dot.title === "Transcribe" ? 2 : 3;
dot.style.background = state.currentStep > stepNum ? "#4ade80" : state.currentStep === stepNum ? "#60a5fa" : "#1e293b";
});
// Update the pipeline steps (non-split loading view)
document.querySelectorAll(".step").forEach(step => {
const icon = step.querySelector(".step-icon");
if (!icon) return;
const stepNum = step.textContent.includes("Download") ? 1 : step.textContent.includes("Transcribe") ? 2 : 3;
step.classList.toggle("active", state.currentStep === stepNum);
step.classList.toggle("done", state.currentStep > stepNum);
if (state.currentStep > stepNum) icon.textContent = "\u2713";
});
// Update the status text in non-split loading view
const statusText = document.querySelector(".status-text");
if (statusText) statusText.textContent = data.message;
return;
} else if (event === "log") {
state.logs.push({ elapsed: data.elapsed, message: data.message, detail: data.detail });
renderLog();
} else if (event === "result") {
const videoChanged = state.videoId !== data.videoId;
state.videoId = data.videoId;
state.videoTitle = data.videoTitle || "";
state.chunks = data.chunks || [];
state.currentType = data.type || "youtube";
state.currentSessionId = data.historyId || null;
state.videoMinimized = false;
if (videoChanged) ytCurrentVideoId = null;
render();
// Refresh history list and re-render when loaded
loadHistory().then(() => render());
} else if (event === "error") {
state.error = data.message;
state.logs.push({ elapsed: "---", message: "ERROR: " + data.message, error: true });
render();
}
}
// ── Render ───────────────────────────────────────────────────────────────
function renderActivationScreen() {
const lic = state.license;
const reasonHints = {
product_mismatch: "This license is for a different product.",
revoked: "This license has been revoked.",
expired: "This license has expired.",
bad_signature: "This license appears tampered.",
not_found: "This license key was not recognized.",
license_status_unreachable: "Couldn't reach the licensing server. Check that the backend is running.",
};
const reasonHint = lic.reason ? (reasonHints[lic.reason] || lic.reason) : null;
const loading = lic.state === "loading" || !lic.loaded;
const buyUrl = upgradeToProUrl();
return `
<div class="activation-screen">
<div class="activation-card">
<h1>Activate YouTube Summarizer</h1>
<p class="activation-sub">
${loading
? "Checking license…"
: "Paste your Keysat license key to unlock this app. Buy a key from the seller, then come back here."
}
</p>
${loading ? "" : `
<label class="activation-label">License key</label>
<textarea class="activation-key" placeholder="LIC1-..." spellcheck="false"
oninput="state.licenseActivationKey=this.value; document.getElementById('activate-btn').disabled = !this.value.trim() || state.licenseActivating">${escHtml(state.licenseActivationKey)}</textarea>
${state.licenseActivationError ? `<div class="activation-error">${escHtml(state.licenseActivationError)}</div>` : ""}
${reasonHint && !state.licenseActivationError ? `<div class="activation-error">${escHtml(reasonHint)}</div>` : ""}
<div class="activation-actions">
<button id="activate-btn" class="activation-btn"
${(!state.licenseActivationKey.trim() || state.licenseActivating) ? "disabled" : ""}
onclick="activateLicense()">
${state.licenseActivating ? "Activating…" : "Activate"}
</button>
<a class="activation-link" href="${escHtml(buyUrl)}" target="_blank" rel="noopener">Buy a key &rarr;</a>
</div>
<div class="activation-meta">
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
${lic.keysatBaseUrl ? ` &middot; Issuer: <strong>${escHtml(lic.keysatBaseUrl.replace(/^https?:\/\//, ""))}</strong>` : ""}
</div>
`}
</div>
</div>
`;
}
function render() {
// Hard-gate the entire UI behind a valid license (matches the server's
// activation-screen flavor). Once licensed + has core, fall through to
// the normal app render below.
if (state.license.loaded && !isLicensed()) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
// Initial paint while license-status is still in-flight: show the
// activation card in its loading skeleton state rather than a flash of
// the underlying app.
if (!state.license.loaded) {
const app = document.getElementById("app");
app.className = "container";
app.innerHTML = renderActivationScreen();
return;
}
savePlayerState();
const app = document.getElementById("app");
const hasResults = state.chunks.length > 0 && !state.loading;
const showSplit = hasResults || (state.loading && (state.videoId || state.currentType === "podcast"));
app.className = showSplit ? "container has-results" : "container";
// Toggle body class for sidebar layout shift
document.body.classList.toggle("history-open", state.historyOpen);
// Preserve library sidebar scroll position across full re-renders
const __prevHistoryListEl = document.querySelector(".history-list");
const __prevHistoryScroll = __prevHistoryListEl ? __prevHistoryListEl.scrollTop : 0;
app.innerHTML = `
<!-- Top bar: title + action icons -->
<div class="top-bar">
<div class="top-left-actions">
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="toggleHistory()" title="Library">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
</svg>
</button>
</div>
<div class="top-bar-input">
<input type="text" class="url-input"
placeholder="${state.loading ? "Paste another URL to queue it..." : "Paste a YouTube video, channel, or podcast RSS URL..."}"
value="${escHtml(state.url)}"
oninput="state.url=this.value; updateInputMode()"
onkeydown="if(event.key==='Enter'){ isSubscribeUrl(state.url) ? addSubscriptionFromInput() : handleSubmit() }" />
<button class="submit-btn"
onclick="${isSubscribeUrl(state.url) ? "addSubscriptionFromInput()" : "handleSubmit()"}"
${!state.url.trim() || (!isSubscribeUrl(state.url) && !state.apiKey.trim() && !state.hasServerKey) ? "disabled" : ""}
style="${isSubscribeUrl(state.url) ? "background:#6366f1" : ""}">
${isSubscribeUrl(state.url) ? (state.addingSubLoading ? "Subscribing..." : "Subscribe") : (state.loading ? "Queue" : "Summarize")}
</button>
</div>
<div class="top-actions">
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
<button class="icon-btn" onclick="toggleClipPanel()" title="Clip Collection (${state.clipCollection.length})" style="position:relative;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"></path>
</svg>
<span class="badge-count">${state.clipCollection.length}</span>
</button>
` : ""}
${hasResults ? `<button class="icon-btn" onclick="closeVideo()" title="Close video">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>` : ""}
<button class="icon-btn ${state.logOpen ? "active" : ""}" onclick="toggleLog()" title="Activity Log">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</button>
<button class="icon-btn ${state.settingsOpen ? "active" : ""} ${state.ytdlpVersion === false || state.ytdlpUpdateAvailable ? "needs-attention" : ""}"
onclick="toggleSettings()" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
<span class="dot"></span>
</button>
</div>
<!-- Mobile hamburger menu (visible ≤600px) -->
<button class="mobile-menu-btn" onclick="toggleMobileMenu(event)">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line>
</svg>
${(state.ytdlpVersion === false || state.ytdlpUpdateAvailable || state.clipCollection.length > 0) ? '<span class="dot" style="display:block;"></span>' : ''}
</button>
<div class="mobile-menu-overlay ${state.mobileMenuOpen ? "open" : ""}" onclick="closeMobileMenu()"></div>
<div class="mobile-menu-dropdown ${state.mobileMenuOpen ? "open" : ""}">
${state.clipCollection.length > 0 && hasEntitlement("clips") ? `
<button class="mobile-menu-item" onclick="closeMobileMenu(); toggleClipPanel()">
<span class="menu-icon">📎</span> Clips
<span class="menu-badge">${state.clipCollection.length}</span>
</button>
` : ""}
${hasResults ? `
<button class="mobile-menu-item" onclick="closeMobileMenu(); closeVideo()">
<span class="menu-icon">✕</span> Close Video
</button>
` : ""}
<button class="mobile-menu-item ${state.logOpen ? "active" : ""}" onclick="closeMobileMenu(); toggleLog()">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 17 10 11 4 5"></polyline><line x1="12" y1="19" x2="20" y2="19"></line>
</svg>
</span> Activity Log
</button>
<button class="mobile-menu-item ${state.settingsOpen ? "active" : ""}" onclick="closeMobileMenu(); toggleSettings()">
<span class="menu-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg>
</span> Settings
</button>
</div>
</div>
<!-- Settings modal -->
${state.settingsOpen ? renderSettingsModal() : ""}
${isSubscribeUrl(state.url) ? `<div id="subscribe-prompt">${renderSubscribePrompt()}</div>` : ""}
${state.queue.length > 0 ? renderQueue() : ""}
${state.error ? `<div class="error-box">${escHtml(state.error)}</div>` : ""}
${state.loading && (state.videoId || state.currentType === "podcast") ? renderLoadingSplit() : ""}
${state.loading && !state.videoId && state.currentType !== "podcast" ? renderLoading() : ""}
${state.chunks.length > 0 && !state.loading ? renderResults() : ""}
${state.logOpen ? renderLogDrawer() : ""}
${state.historyOpen ? renderHistorySidebar() : ""}
${state.clipPanelOpen ? renderClipPanel() : ""}
`;
// Re-init player if the yt-player div exists
if (state.videoId && ytReady && document.getElementById("yt-player")) {
setTimeout(() => initPlayer(state.videoId), 50);
}
// Init podcast audio player if present
if (document.getElementById("podcast-audio")) {
setTimeout(initPodcastPlayer, 50);
}
// Restore lastSeekTarget so toggle-play still works after render
lastSeekTarget = savedLastSeekTarget;
// Restore library sidebar scroll position so deleting/editing items
// doesn't bounce the user back to the top of the list
if (state.historyOpen && __prevHistoryScroll > 0) {
const newList = document.querySelector(".history-list");
if (newList) newList.scrollTop = __prevHistoryScroll;
}
}
function renderServerStatus() {
const s = state.serverStatus;
const lan = state.lanMode;
if (s === "connecting") {
return `<span class="server-status sleeping" title="Connecting to server..."><span class="status-dot"></span>Connecting</span>`;
}
if (s === "disconnected") {
return `<span class="server-status disconnected" title="Server is not responding. Relaunch the app to reconnect."><span class="status-dot"></span>Offline</span>`;
}
if (s === "sleeping") {
return `<span class="server-status sleeping" title="Server is sleeping. It will wake when a browser connects."><span class="status-dot"></span>Sleeping</span>`;
}
// connected
const modeLabel = lan === true ? " · Home" : lan === false ? " · Travel" : "";
const modeTitle = lan === true ? "Connected — other devices on your Wi-Fi can access" : lan === false ? "Connected — only this computer can access" : "Connected to server";
return `<span class="server-status connected" title="${modeTitle}"><span class="status-dot"></span>Connected${modeLabel}</span>`;
}
function renderLicenseBlock() {
const lic = state.license;
const tier = !isLicensed()
? { label: "Unlicensed", className: "unlicensed" }
: isProTier()
? { label: "Pro", className: "pro" }
: { label: "Core", className: "core" };
const expiresLine = lic.expiresAt
? `Expires ${new Date(lic.expiresAt).toLocaleDateString()}`
: (isLicensed() ? "Never expires" : "");
return `
<div class="license-block">
<div class="lic-row">
<strong style="font-size:12px;color:#e2e8f0;">License</strong>
<span class="lic-tier ${tier.className}">${tier.label}${lic.isTrial ? " (trial)" : ""}</span>
</div>
${isLicensed() ? `
<div class="lic-meta">
${lic.licenseId ? `<div>ID: <span class="lic-id">${escHtml(lic.licenseId.slice(0, 8))}…</span></div>` : ""}
${expiresLine ? `<div>${expiresLine}</div>` : ""}
<div>Entitlements: ${(lic.entitlements || []).map(e => escHtml(e)).join(", ") || "none"}</div>
</div>
<div class="lic-actions">
${!isProTier() ? `<a class="lic-btn" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener" style="background:#a855f7;color:#fff;border-color:#a855f7;">Upgrade to Pro</a>` : ""}
<button class="lic-btn danger" onclick="deactivateLicense()">Deactivate</button>
</div>
` : `
<div class="lic-meta">No active license. Activate one below to unlock the app.</div>
`}
</div>
`;
}
function renderProUpsell(featureName, description) {
return `
<div class="pro-upsell">
<div class="pro-title">${escHtml(featureName)} &middot; Pro feature</div>
<div class="pro-desc">${escHtml(description)}</div>
<a class="pro-cta" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener">Upgrade to Pro &rarr;</a>
</div>
`;
}
function renderSettingsModal() {
return `
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
<div class="settings-modal">
<div class="settings-modal-header">
<h2>Settings</h2>
<button class="close-btn" onclick="toggleSettings()">&times;</button>
</div>
<div class="settings-modal-body">
${renderLicenseBlock()}
<label class="field-label">API Key</label>
${state.hasServerKey ? `
<div class="ytdlp-status ytdlp-ok" style="margin-top:0;margin-bottom:8px">
<span>Server key configured</span>
</div>
` : ""}
<div class="key-row">
<input type="${state.showKey ? "text" : "password"}"
class="key-input" placeholder="${state.hasServerKey ? "Using server key (override here)" : "AIza..."}"
value="${escHtml(state.apiKey)}"
oninput="setApiKey(this.value)" />
<button class="key-toggle" onclick="toggleShowKey()">${state.showKey ? "Hide" : "Show"}</button>
</div>
<p class="key-hint">${state.hasServerKey
? "A shared key is set on the server. Enter one here to override it on this device only."
: "Saved locally in your browser. Or set GEMINI_API_KEY in the .env file to share across all devices."
}</p>
<label class="field-label">Analysis Model</label>
<div class="model-grid">
${MODELS.map(m => `
<button class="model-btn ${state.model === m ? "active" : ""}"
onclick="setModel('${m}')">${m.replace("-preview","")}</button>
`).join("")}
</div>
<p class="model-note">Transcription always uses Flash for speed. This model handles topic analysis.</p>
${renderYtdlpStatus()}
${renderCookieStatus()}
${hasEntitlement("subscriptions")
? renderSubscriptions()
: `<label class="field-label">Subscriptions</label>${renderProUpsell("Channel subscriptions", "Subscribe to YouTube channels and podcast feeds, then auto-process new uploads on a schedule. Available on the Pro tier.")}`}
${hasEntitlement("library")
? renderLibraryTransfer()
: `<label class="field-label">Library Transfer</label>${renderProUpsell("Library import/export", "Bulk-export your full library (summaries, folders, subscriptions) and re-import it on another instance. Available on the Pro tier.")}`}
</div>
</div>
</div>
`;
}
// ── Library Transfer ──────────────────────────────────────────────────
function renderLibraryTransfer() {
return '<label class="field-label" style="margin-top:12px;">Library Transfer</label>' +
'<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.5;">Export your full library (summaries, folders, subscriptions) to transfer between devices, or import a library from another instance.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' +
'<button onclick="exportLibrary()" style="padding:6px 14px;font-size:12px;font-weight:600;background:#6366f1;color:#fff;border:none;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#4f46e5\'" onmouseout="this.style.background=\'#6366f1\'">Export Library</button>' +
'<label style="display:inline-flex;align-items:center;gap:6px;padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#334155\';this.style.color=\'#e2e8f0\'" onmouseout="this.style.background=\'#1e293b\';this.style.color=\'#94a3b8\'">' +
'Import Library' +
'<input type="file" accept=".json" style="display:none" onchange="importLibrary(this.files[0])">' +
'</label>' +
'</div>' +
'<div id="library-transfer-result"></div>' +
'</div>';
}
async function exportLibrary() {
try {
const res = await fetch(`${API_BASE}/api/library/export`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "youtube-summarizer-library.json";
a.click();
URL.revokeObjectURL(url);
showToast("Library exported!", "✓");
} catch (e) {
showToast("Export failed: " + e.message, "!");
}
}
async function importLibrary(file) {
if (!file) return;
const resultEl = document.getElementById("library-transfer-result");
try {
if (resultEl) resultEl.innerHTML = '<span style="color:#fbbf24;font-size:12px;">Importing...</span>';
const text = await file.text();
const data = JSON.parse(text);
const res = await fetch(`${API_BASE}/api/library/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await res.json();
if (result.error) throw new Error(result.error);
if (resultEl) resultEl.innerHTML = '<span style="color:#4ade80;font-size:12px;">Imported ' + result.imported + ' sessions (' + result.skipped + ' already existed)</span>';
await loadHistory();
render();
showToast("Library imported: " + result.imported + " sessions added", "✓");
} catch (e) {
if (resultEl) resultEl.innerHTML = '<span style="color:#f87171;font-size:12px;">Import failed: ' + escHtml(e.message) + '</span>';
showToast("Import failed: " + e.message, "!");
}
}
// ── Subscriptions ──────────────────────────────────────────────────────
// Update the submit button and subscribe prompt without a full re-render
function updateInputMode() {
const btn = document.querySelector(".top-bar-input .submit-btn");
const promptEl = document.getElementById("subscribe-prompt");
const isCh = isSubscribeUrl(state.url);
const hasKey = state.apiKey.trim() || state.hasServerKey;
if (btn) {
if (isCh) {
btn.textContent = state.addingSubLoading ? "Subscribing..." : "Subscribe";
btn.disabled = !state.url.trim() || state.addingSubLoading;
btn.style.background = "#6366f1";
btn.onclick = () => addSubscriptionFromInput();
} else {
btn.textContent = state.loading ? "Queue" : "Summarize";
btn.disabled = !state.url.trim() || !hasKey;
btn.style.background = "";
btn.onclick = () => handleSubmit();
}
}
if (isCh && !promptEl) {
const topBar = document.querySelector(".top-bar");
if (topBar) {
const div = document.createElement("div");
div.id = "subscribe-prompt";
div.innerHTML = renderSubscribePrompt();
topBar.parentNode.insertBefore(div, topBar.nextSibling);
}
} else if (!isCh && promptEl) {
promptEl.remove();
}
}
function renderSubscribePrompt() {
const todayStr = new Date().toISOString().slice(0, 10);
return `
<div style="display:flex; flex-wrap:wrap; align-items:center; gap:8px 12px; margin-top:8px; padding:10px 12px;
background:rgba(139,92,246,0.06); border:1px solid rgba(139,92,246,0.15);
border-radius:10px;">
<span style="font-size:16px">📡</span>
<span style="font-size:12px; color:#a78bfa; font-weight:500; flex:1; min-width:160px;">
Channel detected — subscribe to track new videos
</span>
<label style="font-size:10px; color:#64748b; white-space:nowrap;">Since:</label>
<input type="date" value="${escHtml(state.addingSubSince || todayStr)}"
oninput="state.addingSubSince=this.value"
title="Only process videos uploaded on or after this date"
style="padding:5px 6px; font-size:11px;
border:1px solid rgba(139,92,246,0.2); border-radius:6px; outline:none;
background:#0f172a; color:#94a3b8; cursor:pointer;" />
<label style="display:inline-flex; align-items:center; gap:6px; font-size:11px; color:${state.addingSubAuto ? "#fbbf24" : "#94a3b8"}; cursor:pointer; white-space:nowrap;"
title="When ON, new videos from this subscription bypass the approval queue and start processing immediately.">
<input type="checkbox" ${state.addingSubAuto ? "checked" : ""}
onchange="state.addingSubAuto=this.checked; render()"
style="accent-color:#fbbf24; cursor:pointer;" />
⚡ Auto-process new videos
</label>
</div>
`;
}
async function addSubscriptionFromInput() {
const url = state.url.trim();
if (!url) return;
const podcast = isPodcastUrl(url);
state.addingSubLoading = true;
render();
try {
const auto = !!state.addingSubAuto;
const res = await fetch(`${API_BASE}/api/subscriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
url,
since: state.addingSubSince || undefined,
type: podcast ? "podcast" : undefined,
autoDownload: auto,
}),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to add subscription");
}
const label = podcast ? url.split("/").pop() || "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
state.url = "";
state.addingSubSince = "";
state.addingSubAuto = false;
state.addingSubLoading = false;
await loadSubscriptions();
render();
showToast(
`Subscribed to ${label}${auto ? " (auto-process ON)" : ""} — checking for ${podcast ? "episodes" : "videos"}...`,
podcast ? "🎙" : (auto ? "⚡" : "📡")
);
} catch (e) {
state.error = e.message;
state.addingSubLoading = false;
render();
}
}
// Single background poll loop — runs every 10s, surgical DOM updates only
let bgPollRunning = false;
function startBgPoll() {
if (bgPollRunning) return;
bgPollRunning = true;
setInterval(async () => {
try {
const oldSubs = JSON.stringify(state.subscriptions);
const oldQueueLen = state.queue.length;
await loadSubscriptions();
const subsChanged = JSON.stringify(state.subscriptions) !== oldSubs;
// Check server auto-queue for new pending items
const res = await fetch(`${API_BASE}/api/auto-queue`);
const data = await res.json();
const items = data.items || [];
for (const item of items) {
if (state.queue.find(q => q.url === item.url)) continue;
state.queue.push({
id: item.id, url: item.url,
videoId: item.videoId || "", title: item.title || "",
uploadDate: item.uploadDate || null,
type: item.type || "youtube",
status: "pending_approval", error: null,
fromSubscription: item.subscriptionName,
});
}
const queueChanged = state.queue.length !== oldQueueLen;
// Surgical DOM updates — no full render
if (subsChanged) {
for (const sub of state.subscriptions) {
const el = document.querySelector(`[data-sub-id="${sub.id}"] .sub-name`);
if (el && el.textContent !== sub.name) el.textContent = sub.name;
const metaEl = document.querySelector(`[data-sub-id="${sub.id}"] .sub-meta`);
if (metaEl) {
const sinceDate = new Date(sub.createdAt).toLocaleDateString();
metaEl.innerHTML = `Since ${sinceDate}${sub.lastChecked ? " · Checked " + timeAgo(sub.lastChecked) : ""}${sub.paused ? " · Paused" : ""}`;
}
}
}
if (queueChanged) {
const newCount = state.queue.length - oldQueueLen;
if (newCount > 0) {
showToast(`${newCount} new video${newCount > 1 ? "s" : ""} ready for review`, "🎬");
}
const queueContainer = document.querySelector(".queue-section");
if (queueContainer) {
const temp = document.createElement("div");
temp.innerHTML = renderQueue();
const newQueue = temp.querySelector(".queue-section");
if (newQueue) queueContainer.replaceWith(newQueue);
} else {
render();
}
}
} catch {}
}, 10000);
}
async function loadSubscriptions() {
try {
const res = await fetch(`${API_BASE}/api/subscriptions`);
const data = await res.json();
state.subscriptions = data.subscriptions || [];
state.subsLoaded = true;
} catch {
state.subscriptions = [];
}
}
async function checkSubscriptionsNow() {
const btn = document.getElementById("check-subs-btn");
if (btn) { btn.disabled = true; btn.textContent = "Checking..."; }
showToast("Checking subscriptions for new videos...", "📡");
state.subCheckLog = [{ msg: "Starting subscription check..." }];
updateSubCheckLogUI();
try {
await fetch(`${API_BASE}/api/subscriptions/check-now`, { method: "POST" });
} catch {}
// Poll for completion and show log
const pollLog = async () => {
try {
const res = await fetch(`${API_BASE}/api/sub-check-log`);
const data = await res.json();
if (data.log.length > 0) {
state.subCheckLog = data.log;
updateSubCheckLogUI();
}
const autoRes = await fetch(`${API_BASE}/api/auto-queue`);
const autoData = await autoRes.json();
if (autoData.checkRunning) {
setTimeout(pollLog, 2000);
} else {
if (btn) { btn.disabled = false; btn.textContent = "Check for new videos now"; }
if (data.autoQueueCount > 0) {
showToast(`${data.autoQueueCount} video(s) pending approval`, "🎬");
} else {
showToast("No new videos found", "✓");
}
}
} catch {}
};
setTimeout(pollLog, 3000);
}
function updateSubCheckLogUI() {
const logEl = document.getElementById("sub-check-log");
if (!logEl) return;
if (state.subCheckLog.length > 0) {
logEl.style.display = "block";
logEl.innerHTML = state.subCheckLog.map(l => `<div style="font-size:11px;color:#94a3b8;padding:1px 0;font-family:'SF Mono',Menlo,monospace;">${escHtml(l.msg)}</div>`).join("");
logEl.scrollTop = logEl.scrollHeight;
}
}
async function addSubscription() {
const url = state.addingSubUrl.trim();
if (!url) return;
const podcast = isPodcastUrl(url);
state.addingSubLoading = true;
render();
try {
const res = await fetch(`${API_BASE}/api/subscriptions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url, since: state.addingSubSince || undefined, type: podcast ? "podcast" : undefined }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Failed to add subscription");
}
const handle = podcast ? "podcast" : (url.match(/@[\w-]+/)?.[0] || "channel");
state.addingSubUrl = "";
state.addingSubSince = "";
state.addingSubLoading = false;
await loadSubscriptions();
render();
showToast(`Subscribed to ${handle} — checking for videos...`, "📡");
} catch (e) {
state.error = e.message;
state.addingSubLoading = false;
render();
}
}
async function removeSubscription(id) {
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}`, { method: "DELETE" });
if (!res.ok) {
console.error("Failed to delete subscription:", await res.text());
return;
}
state.subscriptions = state.subscriptions.filter(s => s.id !== id);
render();
} catch (err) {
console.error("Error deleting subscription:", err);
}
}
async function updateSubSince(id, dateStr) {
if (!dateStr) return;
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/since`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ since: dateStr }),
});
if (!res.ok) return;
const updated = await res.json();
const sub = state.subscriptions.find(s => s.id === id);
if (sub) sub.createdAt = updated.createdAt;
render();
showToast(`Updated since date — check for videos to pull historical content`, "📅");
} catch {}
}
async function togglePauseSubscription(id) {
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/pause`, { method: "PUT" });
const updated = await res.json();
const sub = state.subscriptions.find(s => s.id === id);
if (sub) sub.paused = updated.paused;
render();
} catch {}
}
async function toggleAutoDownload(id) {
const sub = state.subscriptions.find(s => s.id === id);
if (!sub) return;
const next = !sub.autoDownload;
// Optimistic update
sub.autoDownload = next;
render();
try {
const res = await fetch(`${API_BASE}/api/subscriptions/${id}/auto-download`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ enabled: next }),
});
const data = await res.json();
if (data && data.subscription) sub.autoDownload = !!data.subscription.autoDownload;
render();
} catch {
// Roll back on failure
sub.autoDownload = !next;
render();
}
}
function renderSubscriptions() {
const todayStr = new Date().toISOString().slice(0, 10);
return `
<div class="sub-section">
<label class="field-label">Subscriptions</label>
<div class="sub-add-row">
<input type="text" placeholder="YouTube channel/playlist URL or podcast RSS feed..."
value="${escHtml(state.addingSubUrl)}"
oninput="state.addingSubUrl=this.value; this.closest('.sub-add-row').querySelector('.sub-add-btn').disabled=!this.value.trim()"
onkeydown="if(event.key==='Enter')addSubscription()"
style="flex:2" />
<input type="date" value="${escHtml(state.addingSubSince || todayStr)}"
oninput="state.addingSubSince=this.value"
title="Only process videos uploaded on or after this date"
style="flex:0 0 auto; padding:9px 8px; font-size:11px;
border:1px solid #1e293b; border-radius:8px; outline:none;
background:#0f172a; color:#94a3b8; cursor:pointer;" />
<button class="sub-add-btn" onclick="addSubscription()"
${!state.addingSubUrl.trim() || state.addingSubLoading ? "disabled" : ""}>
${state.addingSubLoading ? "Adding..." : "Subscribe"}
</button>
</div>
<p style="font-size:10px; color:#475569; margin:-4px 0 8px;">Date = only process videos/episodes published on or after this date. Defaults to today.</p>
${state.subscriptions.length === 0
? `<div class="sub-empty">No subscriptions yet. Add a channel URL or podcast RSS feed.</div>`
: state.subscriptions.map(sub => {
const sinceDate = new Date(sub.createdAt).toLocaleDateString();
const sinceIso = new Date(sub.createdAt).toISOString().slice(0, 10);
return `
<div class="sub-item ${sub.paused ? "paused" : ""}" data-sub-id="${sub.id}">
<span class="sub-icon">${sub.paused ? "\u23F8" : (sub.type === "podcast" ? "\uD83C\uDFA7" : "\uD83D\uDCE1")}</span>
<div class="sub-info">
<div class="sub-name">${escHtml(sub.name)}</div>
<div class="sub-meta">
<span class="sub-since-link" onclick="event.stopPropagation(); this.nextElementSibling.showPicker?.()" title="Click to change date" style="cursor:pointer; text-decoration:underline dotted; text-underline-offset:2px;">Since ${sinceDate}</span><input type="date" value="${sinceIso}" style="position:absolute;opacity:0;width:0;height:0;pointer-events:none;" onchange="updateSubSince('${sub.id}', this.value)" />
${sub.lastChecked ? " · Checked " + timeAgo(sub.lastChecked) : ""}
${sub.paused ? " · Paused" : ""}
${sub.autoDownload ? ' · <span style="color:#fbbf24">\u26A1 Auto-process</span>' : ""}
</div>
</div>
<div class="sub-actions">
<button class="sub-action" onclick="event.stopPropagation(); toggleAutoDownload('${sub.id}')"
title="${sub.autoDownload ? "Auto-process: ON — new videos skip the queue and process immediately. Click to disable." : "Auto-process: OFF — new videos require approval in the queue. Click to enable auto-processing."}"
style="${sub.autoDownload ? "color:#fbbf24" : ""}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="${sub.autoDownload ? "#fbbf24" : "none"}" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"></polygon>
</svg>
</button>
<button class="sub-action" onclick="event.stopPropagation(); togglePauseSubscription('${sub.id}')"
title="${sub.paused ? "Resume" : "Pause"}">
${sub.paused ? "\u25B6" : "\u23F8"}
</button>
<button class="sub-action danger" onclick="event.stopPropagation(); removeSubscription('${sub.id}')"
title="Unsubscribe">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
</div>
`;
}).join("")
}
${state.subscriptions.length > 0 ? `
<button id="check-subs-btn" onclick="checkSubscriptionsNow()" style="
margin-top: 10px; padding: 7px 14px; font-size: 11px; font-weight: 600;
background: none; color: #64748b; border: 1px solid #1e293b; border-radius: 6px;
cursor: pointer; transition: all 0.15s; width: 100%;
" onmouseover="this.style.background='#1e293b';this.style.color='#94a3b8'"
onmouseout="this.style.background='none';this.style.color='#64748b'">
Check for new videos now
</button>
<div id="sub-check-log" tabindex="0" onkeydown="if((event.ctrlKey||event.metaKey)&&event.key==='a'){event.preventDefault();var r=document.createRange();r.selectNodeContents(this);var s=window.getSelection();s.removeAllRanges();s.addRange(r)}" style="${state.subCheckLog.length > 0 ? '' : 'display:none;'} margin-top:8px; padding:8px 10px; background:#0a0e17; border:1px solid #1e293b; border-radius:8px; max-height:200px; overflow-y:auto; user-select:text; outline:none;">
${state.subCheckLog.map(l => `<div style="font-size:11px;color:#94a3b8;padding:1px 0;font-family:'SF Mono',Menlo,monospace;">${escHtml(l.msg)}</div>`).join("")}
</div>
` : ""}
</div>
`;
}
// ── Auto-queue polling ────────────────────────────────────────────────────
// Fetch pending items from server auto-queue into frontend state.
// Returns true if new items were added.
async function pollAutoQueue() {
try {
const res = await fetch(`${API_BASE}/api/auto-queue`);
const data = await res.json();
const items = data.items || [];
if (items.length === 0) return false;
let added = false;
for (const item of items) {
if (state.queue.find(q => q.url === item.url)) continue;
state.queue.push({
id: item.id, url: item.url,
videoId: item.videoId || "", title: item.title || "",
uploadDate: item.uploadDate || null,
type: item.type || "youtube",
status: "pending_approval", error: null,
fromSubscription: item.subscriptionName,
});
added = true;
}
return added;
} catch { return false; }
}
function renderLoadingSplit() {
const steps = [
{ num: 1, label: "Download", icon: "\u2B07" },
{ num: 2, label: "Transcribe", icon: "\uD83C\uDFA4" },
{ num: 3, label: "Analyze", icon: "\uD83E\uDDE0" },
];
const isPod = state.currentType === "podcast";
if (isPod) {
return `
<div style="padding: 0 4px;">
<div style="display:flex; align-items:center; gap:10px; padding:12px 0 8px; border-bottom:1px solid #1e293b; margin-bottom:12px;">
<span style="font-size:20px;">🎙</span>
<div style="flex:1; min-width:0;">
<div style="font-size:14px; font-weight:600; color:#e2e8f0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(state.videoTitle || "Podcast Episode")}</div>
<div style="font-size:11px; color:#64748b;">Processing podcast audio...</div>
</div>
<div class="loading-status-bar" style="flex:0 0 auto; background:none; padding:0; border:none;">
<div class="spinner-sm"></div>
<span class="status-msg" style="font-size:11px;">${escHtml(state.status || "Processing...")}</span>
<div style="display:flex;gap:4px; margin-left:8px;">
${steps.map(s => `
<span style="width:8px;height:8px;border-radius:50%;
background:${state.currentStep > s.num ? "#4ade80" : state.currentStep === s.num ? "#60a5fa" : "#1e293b"};
transition:background 0.3s;" title="${s.label}"></span>
`).join("")}
</div>
</div>
</div>
<div class="chunks-scroll" style="max-height: calc(100vh - 200px);">
${[1,2,3,4,5,6].map(i => `
<div class="skeleton-chunk" style="animation-delay:${i * 0.1}s">
<div class="skeleton-line title" style="animation-delay:${i * 0.15}s"></div>
<div class="skeleton-line subtitle" style="animation-delay:${i * 0.15 + 0.05}s"></div>
<div class="skeleton-line short" style="animation-delay:${i * 0.15 + 0.1}s"></div>
</div>
`).join("")}
</div>
</div>`;
}
return `
<div class="results-split">
<div class="results-left">
<div class="video-embed">
<div id="yt-player"></div>
</div>
</div>
<div class="results-right">
<div class="loading-status-bar">
<div class="spinner-sm"></div>
<span class="status-msg">${escHtml(state.status || "Processing...")}</span>
<div style="flex:1"></div>
<div style="display:flex;gap:4px">
${steps.map(s => `
<span style="width:8px;height:8px;border-radius:50%;
background:${state.currentStep > s.num ? "#4ade80" : state.currentStep === s.num ? "#60a5fa" : "#1e293b"};
transition:background 0.3s;" title="${s.label}"></span>
`).join("")}
</div>
</div>
<div class="chunks-scroll">
${[1,2,3,4,5,6].map(i => `
<div class="skeleton-chunk" style="animation-delay:${i * 0.1}s">
<div class="skeleton-line title" style="animation-delay:${i * 0.15}s"></div>
<div class="skeleton-line subtitle" style="animation-delay:${i * 0.15 + 0.05}s"></div>
<div class="skeleton-line short" style="animation-delay:${i * 0.15 + 0.1}s"></div>
</div>
`).join("")}
</div>
</div>
</div>
`;
}
function renderLoading() {
const steps = [
{ num: 1, label: "Download audio", icon: "\u2B07" },
{ num: 2, label: "Transcribe", icon: "\uD83C\uDFA4" },
{ num: 3, label: "Analyze topics", icon: "\uD83E\uDDE0" },
];
return `
<div class="loading">
<div class="pipeline">
${steps.map((s, i) => `
${i > 0 ? '<span class="step-arrow">\u2192</span>' : ""}
<div class="step ${state.currentStep === s.num ? "active" : ""} ${state.currentStep > s.num ? "done" : ""}">
<span class="step-icon">${state.currentStep > s.num ? "\u2713" : s.icon}</span>
${s.label}
</div>
`).join("")}
</div>
<div class="spinner"></div>
<p class="status-text">${escHtml(state.status || "Processing...")}</p>
</div>
`;
}
function renderResults() {
const totalEntries = state.chunks.reduce((sum, c) => sum + c.entries.length, 0);
const lastChunk = state.chunks[state.chunks.length - 1];
const lastEntry = lastChunk.entries[lastChunk.entries.length - 1];
const totalDuration = lastEntry ? lastEntry.offset : 0;
const isPod = state.currentType === "podcast";
if (isPod) {
return `
<div style="padding: 0 4px;">
<div style="display:flex; align-items:center; gap:10px; padding:12px 0 8px;">
<span style="font-size:20px;">🎙</span>
<div style="flex:1; min-width:0;">
<div style="font-size:14px; font-weight:600; color:#e2e8f0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;">${escHtml(state.videoTitle || "Podcast Episode")}</div>
<div style="font-size:11px; color:#64748b;">${state.chunks.length} topics &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
</div>
<button class="expand-btn" onclick="toggleVideoMinimize()" title="${state.videoMinimized ? "Show player" : "Hide player"}" style="margin-right:4px;">
${state.videoMinimized ? "Show Player" : "Hide Player"}
</button>
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
${!state.videoMinimized && state.url ? `
<audio id="podcast-audio" preload="none" style="width:100%; height:36px; margin-bottom:12px; border-radius:8px; outline:none;"
controls src="${escHtml(state.url)}"></audio>
` : ""}
<div style="border-top:1px solid #1e293b; padding-top:12px;"></div>
<div class="chunks-scroll" style="max-height: calc(100vh - ${state.videoMinimized ? "200" : "260"}px);">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
</div>
</div>
`;
}
return `
<div class="results-split">
<button class="landscape-back" onclick="screen.orientation.lock('portrait-primary').catch(()=>{})" title="Exit fullscreen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div class="results-left ${state.videoMinimized ? "minimized" : ""}" style="position:relative;">
${state.videoId ? `
<button class="minimize-toggle" onclick="toggleVideoMinimize()" title="Minimize video">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"></polyline><polyline points="20 10 14 10 14 4"></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</button>
<div class="video-mini-bar" onclick="toggleVideoMinimize()">
<span class="mini-btn" onclick="event.stopPropagation(); togglePlayPause()" title="Play/Pause">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"></polygon></svg>
</span>
<span class="mini-title">${escHtml(state.videoTitle || "Video")}</span>
<span class="mini-btn" title="Expand video">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</span>
</div>
<div class="video-embed">
<div id="yt-player"></div>
</div>
${state.videoTitle ? `<div class="video-title">${escHtml(state.videoTitle)}</div>` : ""}
<div class="video-meta">${state.chunks.length} topics &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
` : ""}
</div>
<div class="results-right">
<div class="stats-bar">
<div class="stats">
<span><strong>${state.chunks.length}</strong> topics</span>
<span><strong>${totalEntries}</strong> segments</span>
<span><strong>${formatTime(totalDuration)}</strong> total</span>
</div>
<div style="display:flex; gap:6px;">
<button class="expand-btn" onclick="exportCurrentPDF()" title="Export PDF">
📄 Export PDF
</button>
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
</div>
<div class="chunks-scroll">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
</div>
</div>
</div>
`;
}
function renderChunk(chunk, index) {
const isExpanded = state.expandAll || state.expandedChunks.has(index);
const startSec = Math.floor(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTime = formatTime(endEntry.offset + (endEntry.duration || 0));
return `
<div class="chunk ${isExpanded ? "expanded" : ""}" id="chunk-${index}">
<div class="chunk-header">
<button class="chunk-play-btn" onclick="event.stopPropagation(); seekTo(${startSec}); highlightChunk(${index})" title="Play from ${formatTime(chunk.startTime)}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"></polygon></svg>
</button>
<div class="chunk-info" onclick="toggleChunk(${index})" style="cursor:pointer">
<div class="chunk-title-row">
<span class="chunk-title">${escHtml(chunk.title)}</span>
<span class="chunk-time">${formatTime(chunk.startTime)} \u2013 ${endTime}</span>
</div>
<p class="chunk-summary">${escHtml(chunk.summary)}</p>
</div>
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn chunk-clip-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index})" title="Add topic to clips">📎</span>` : ""}
<div class="chunk-arrow" onclick="toggleChunk(${index})" style="cursor:pointer">\u25BE</div>
</div>
<div class="chunk-body">
<div class="chunk-body-inner">
${chunk.entries.map((entry, ei) => `
<button class="transcript-line" data-offset="${entry.offset}" onclick="seekTo(${Math.floor(entry.offset)})"
title="Play from ${formatTime(entry.offset)}">
<span class="ts-badge">\u25B6 ${formatTime(entry.offset)}</span>
<span class="transcript-text">${escHtml(entry.text)}</span>
${state.currentSessionId && hasEntitlement("clips") ? `<span class="clip-line-btn" onclick="event.stopPropagation(); addToClipCollection('${state.currentSessionId}', ${index}, ${ei})" title="Add this line to clips">📎</span>` : ""}
</button>
`).join("")}
</div>
</div>
</div>
`;
}
function highlightChunk(index) {
document.querySelectorAll(".chunk.now-playing").forEach(el => el.classList.remove("now-playing"));
const el = document.getElementById("chunk-" + index);
if (el) el.classList.add("now-playing");
}
// ── Actions ──────────────────────────────────────────────────────────────
function renderYtdlpStatus() {
if (state.ytdlpVersion === null && !state.ytdlpUpdateAvailable) {
return `<div class="ytdlp-status" style="color:#475569;background:rgba(255,255,255,0.02);border:1px solid #1e293b">Checking yt-dlp...</div>`;
}
if (state.ytdlpVersion === false) {
return `<div class="ytdlp-status ytdlp-err">
<span>yt-dlp not installed \u2014 required for audio download</span>
</div>`;
}
if (state.ytdlpUpdateAvailable) {
return `<div class="ytdlp-status ytdlp-warn">
<span>yt-dlp <strong>${escHtml(state.ytdlpVersion)}</strong> installed \u2014 <strong>${escHtml(state.ytdlpLatest)}</strong> available</span>
<button class="update-btn" onclick="updateYtdlp()" ${state.ytdlpUpdating ? "disabled" : ""}>
${state.ytdlpUpdating ? "Updating..." : "Update now"}
</button>
</div>`;
}
return `<div class="ytdlp-status ytdlp-ok">
<span>yt-dlp <strong>${escHtml(state.ytdlpVersion)}</strong> \u2014 up to date</span>
</div>`;
}
function renderCookieStatus() {
const uploadBtn = '<label style="display:inline-flex;align-items:center;gap:6px;padding:5px 12px;font-size:11px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;transition:all 0.15s;" ' +
'onmouseover="this.style.background=\'#334155\';this.style.color=\'#e2e8f0\'" onmouseout="this.style.background=\'#1e293b\';this.style.color=\'#94a3b8\'">' +
'Upload cookies.txt' +
'<input type="file" accept=".txt,.cookies" style="display:none" onchange="uploadCookieFile(this.files[0])">' +
'</label>';
const testBtn = '<button onclick="testCookies()" style="padding:5px 12px;font-size:11px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;" ' +
'onmouseover="this.style.background=\'#334155\'" onmouseout="this.style.background=\'#1e293b\'">Test Cookies</button>';
const deleteBtn = '<button onclick="deleteCookieFile()" style="padding:5px 12px;font-size:11px;font-weight:600;background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.2);border-radius:6px;cursor:pointer;">Remove</button>';
let html = "";
// ── PO Token Plugin status (most important for bot detection) ──
{
html += '<label class="field-label">YouTube Bot Detection</label>' +
'<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:6px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="font-size:12px;color:#e2e8f0;">Smart retry: <strong style="color:#4ade80;">enabled</strong></span>' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.5;">' +
'If YouTube blocks a download ("Sign in to confirm you\'re not a bot"), the app ' +
'will automatically wait and retry up to 3 times with increasing delays (30s, 60s, 2min).<br><br>' +
'<span style="color:#64748b;">Common causes of blocks: VPN/proxy use, too many downloads in a short period, ' +
'or YouTube\'s general anti-bot measures. Turning off VPN and waiting usually resolves it.</span></span>' +
'</div>';
}
// ── Cookie status ──
let cookieHtml = "";
if (state.cookieMethod === "none") {
cookieHtml = '<div class="ytdlp-status" style="flex-direction:column;align-items:flex-start;gap:8px;border-color:#334155;background:rgba(30,41,59,0.3);">' +
'<span style="color:#94a3b8;">No YouTube cookies configured</span>' +
'<span style="font-size:11px;color:#64748b;line-height:1.5;">' +
'Cookies authenticate your server with YouTube to avoid bot detection blocks and access restricted videos.<br><br>' +
'<strong style="color:#94a3b8;">To set up:</strong><br>' +
'1. Install the "Get cookies.txt LOCALLY" browser extension on your laptop<br>' +
'2. Go to youtube.com and make sure you\'re signed in<br>' +
'3. Click the extension icon and export cookies for youtube.com<br>' +
'4. Upload the downloaded cookies.txt file here<br><br>' +
'<span style="color:#fbbf24;">Note:</span> Cookies expire after ~2 weeks. You\'ll need to re-upload periodically.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + '</div>' +
'</div>';
} else if (state.cookieMethod === "cookies.txt") {
const age = state.cookieFileAgeDays;
const ageStr = (age !== null && age !== undefined) ? (age + " day" + (age !== 1 ? "s" : "") + " old") : "";
if (state.cookieFileExpiring) {
cookieHtml = '<div class="ytdlp-status ytdlp-warn" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>cookies.txt is ' + ageStr + ' \u2014 likely expiring soon</span>' +
'<span style="font-size:11px;color:#94a3b8;line-height:1.4;">Cookies typically expire after ~14 days. Upload a fresh cookies.txt to keep Premium/ad-free downloads.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + ' ' + deleteBtn + '</div>' +
'</div>';
} else {
cookieHtml = '<div class="ytdlp-status ytdlp-ok" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>YouTube auth: <strong>cookies.txt</strong>' + (ageStr ? ' (' + ageStr + ')' : '') + '</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + ' ' + deleteBtn + '</div>' +
'</div>';
}
} else {
// Browser cookies
cookieHtml = '<div class="ytdlp-status ytdlp-ok" style="flex-direction:column;align-items:flex-start;gap:8px;">' +
'<span>YouTube auth: <strong>' + escHtml(state.cookieMethod) + '</strong> browser cookies</span>' +
'<span style="font-size:11px;color:#94a3b8;">Stay signed into YouTube in ' + escHtml(state.cookieMethod) + '. For remote servers, upload a cookies.txt instead.</span>' +
'<div style="display:flex;gap:6px;flex-wrap:wrap;">' + uploadBtn + ' ' + testBtn + '</div>' +
'</div>';
}
html += '<label class="field-label" style="margin-top:12px;">YouTube Cookies</label>' + cookieHtml;
return html + '<div id="cookie-test-result"></div>';
}
async function uploadCookieFile(file) {
if (!file) return;
try {
const text = await file.text();
const res = await fetch(API_BASE + "/api/cookies/upload", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: text,
});
const data = await res.json();
if (data.error) {
showCookieResult(data.error, true);
return;
}
// Refresh cookie status
const statusRes = await fetch(API_BASE + "/api/cookies/status");
const status = await statusRes.json();
state.cookieMethod = status.method || "cookies.txt";
state.cookieFileAgeDays = status.fileAgeDays;
state.cookieFileExpiring = status.fileExpiring || false;
showCookieResult("Cookies uploaded successfully!", false);
render();
} catch (e) {
showCookieResult("Upload failed: " + e.message, true);
}
}
async function deleteCookieFile() {
if (!confirm("Remove the cookies.txt file? YouTube may block downloads without authentication.")) return;
try {
await fetch(API_BASE + "/api/cookies/delete", { method: "POST" });
state.cookieMethod = "none";
state.cookieFileAgeDays = null;
state.cookieFileExpiring = false;
render();
} catch (e) {
showCookieResult("Delete failed: " + e.message, true);
}
}
async function testCookies() {
showCookieResult("Testing cookies...", false);
try {
const res = await fetch(API_BASE + "/api/cookies/test", { method: "POST" });
const data = await res.json();
showCookieResult(data.ok ? data.message : data.error, !data.ok);
} catch (e) {
showCookieResult("Test failed: " + e.message, true);
}
}
function showCookieResult(msg, isError) {
const el = document.getElementById("cookie-test-result");
if (!el) return;
el.innerHTML = '<div style="margin-top:6px;padding:8px 12px;border-radius:6px;font-size:12px;' +
(isError ? 'background:rgba(239,68,68,0.1);color:#f87171;border:1px solid rgba(239,68,68,0.2);' :
'background:rgba(34,197,94,0.1);color:#4ade80;border:1px solid rgba(34,197,94,0.2);') +
'">' + escHtml(msg) + '</div>';
// Auto-clear after 8 seconds
setTimeout(() => { if (el) el.innerHTML = ""; }, 8000);
}
async function updateYtdlp() {
state.ytdlpUpdating = true;
render();
try {
const res = await fetch(API_BASE + "/api/update-ytdlp", { method: "POST" });
const data = await res.json();
state.ytdlpVersion = data.version || state.ytdlpVersion;
state.ytdlpLatest = data.latestVersion;
state.ytdlpUpdateAvailable = data.updateAvailable || false;
} catch (e) {
state.error = "Failed to update yt-dlp: " + e.message;
} finally {
state.ytdlpUpdating = false;
render();
}
}
function renderLogDrawer() {
return `
<div class="log-drawer-overlay" onclick="toggleLog()"></div>
<div class="log-drawer">
<div class="log-drawer-header">
<h2>Activity Log</h2>
<button class="close-btn" onclick="toggleLog()">&times;</button>
</div>
<div class="log-drawer-body" id="log-body">
${state.logs.length === 0
? `<div class="log-empty">No activity yet. Submit a URL to see the processing log.</div>`
: state.logs.map(l => l.separator ? `
<div class="log-entry" style="border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px;">
<span class="log-msg">${escHtml(l.message)}</span>
</div>` : `
<div class="log-entry ${l.error ? "error" : ""} ${l.message.includes("Pipeline finished") ? "done" : ""} ${l.message.includes("cost:") || l.message.includes("tokens:") ? "cost" : ""}">
<span class="log-time">${l.elapsed}s</span>
<span class="log-msg">${escHtml(l.message)}${l.detail ? ` <span class="log-detail">(${escHtml(l.detail)})</span>` : ""}</span>
</div>
`).join("")}
</div>
</div>
`;
}
function renderLog() {
const el = document.getElementById("log-body");
if (el && state.logOpen) {
const last = state.logs[state.logs.length - 1];
if (last) {
const entry = document.createElement("div");
if (last.separator) {
entry.className = "log-entry";
entry.style.cssText = "border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px;";
entry.innerHTML = `<span class="log-msg">${escHtml(last.message)}</span>`;
} else {
entry.className = "log-entry" + (last.error ? " error" : "") + (last.message.includes("Pipeline finished") ? " done" : "") + (last.message.includes("cost:") || last.message.includes("tokens:") ? " cost" : "");
entry.innerHTML = `<span class="log-time">${last.elapsed}s</span><span class="log-msg">${escHtml(last.message)}${last.detail ? ` <span class="log-detail">(${escHtml(last.detail)})</span>` : ""}</span>`;
}
el.appendChild(entry);
el.scrollTop = el.scrollHeight;
}
}
}
function toggleQueueCollapse() {
state.queueCollapsed = !state.queueCollapsed;
render();
}
function renderQueue() {
const processingItem = state.queue.find(q => q.status === "processing");
const pendingCount = state.queue.filter(q => q.status === "pending_approval").length;
const queuedCount = state.queue.filter(q => q.status === "queued").length;
const summaryParts = [];
if (processingItem) summaryParts.push("processing 1");
if (queuedCount) summaryParts.push(`${queuedCount} queued`);
if (pendingCount) summaryParts.push(`${pendingCount} pending`);
return `
<div class="queue-section">
<div class="queue-label" onclick="toggleQueueCollapse()" style="cursor: pointer; display: flex; align-items: center; gap: 6px; user-select: none;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" style="transition: transform 0.2s; transform: rotate(${state.queueCollapsed ? '-90deg' : '0'});">
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
Queue (${state.queue.length})
${state.queueCollapsed && summaryParts.length ? `<span style="font-size: 10px; color: #818cf8; font-weight: 400; text-transform: none; letter-spacing: 0;">— ${summaryParts.join(", ")}</span>` : ""}
${!state.queueCollapsed && pendingCount > 1 ? `<button class="queue-approve-all" onclick="event.stopPropagation(); approveAllQueue()">Approve All (${pendingCount})</button>` : ""}
</div>
${state.queueCollapsed ? "" : state.queue.map((q, i) => {
if (q.status === "pending_approval") {
const dateStr = q.uploadDate ? (() => {
const d = q.uploadDate;
const dt = new Date(d.slice(0,4) + "-" + d.slice(4,6) + "-" + d.slice(6,8));
return dt.toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
})() : "";
return `
<div class="queue-item pending-approval">
<span class="queue-pos" style="background: #1e293b; color: ${q.type === 'podcast' ? '#34d399' : '#818cf8'}; font-size: 10px;">${q.type === 'podcast' ? '🎙' : 'NEW'}</span>
<span class="queue-title" title="${escHtml(q.url)}">${escHtml(q.title || q.url)}</span>
${dateStr ? `<span style="font-size:10px; color:#64748b; flex-shrink:0; white-space:nowrap;">${dateStr}</span>` : ""}
${q.fromSubscription ? `<span class="queue-from">${escHtml(q.fromSubscription)}</span>` : ""}
<span class="queue-actions">
<button class="queue-approve" onclick="approveQueueItem('${q.id}')" title="Approve">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="20 6 9 17 4 12"></polyline></svg>
</button>
<button class="queue-reject" onclick="rejectQueueItem('${q.id}')" title="Reject (skip)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</span>
</div>`;
}
return `
<div class="queue-item ${q.status === "processing" ? "processing" : ""}">
<span class="queue-pos">${q.status === "processing" ? `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><circle cx="12" cy="12" r="10" stroke-dasharray="32" stroke-dashoffset="12"><animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite"/></circle></svg>` : i + 1}</span>
<span class="${q.title ? 'queue-title' : 'queue-url'}" title="${escHtml(q.url)}">${escHtml(q.title || q.url)}</span>
${q.fromSubscription ? `<span class="queue-from">${escHtml(q.fromSubscription)}</span>` : ""}
<span class="queue-status">${q.status === "processing" ? "Processing..." : "Queued"}</span>
<button class="queue-remove" onclick="removeFromQueue('${q.id}')" title="Remove">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>`;
}).join("")}
</div>
`;
}
function toggleLog() { state.logOpen = !state.logOpen; render(); }
function toggleMobileMenu(e) {
e && e.stopPropagation();
state.mobileMenuOpen = !state.mobileMenuOpen;
render();
}
function closeMobileMenu() {
if (state.mobileMenuOpen) { state.mobileMenuOpen = false; render(); }
}
function toggleSettings() {
state.settingsOpen = !state.settingsOpen;
if (state.settingsOpen && !state.subsLoaded) loadSubscriptions();
render();
}
// ── History ───────────────────────────────────────────────────────────
let historyAnimateIn = false;
async function toggleHistory() {
state.historyOpen = !state.historyOpen;
historyAnimateIn = state.historyOpen; // only animate when opening
if (state.historyOpen && !state.historyLoaded) {
await loadHistory();
}
render();
// Clear the flag after render so re-renders don't re-animate
setTimeout(() => { historyAnimateIn = false; }, 300);
}
async function loadHistory() {
try {
const res = await fetch(`${API_BASE}/api/history`);
const data = await res.json();
state.historySessions = data.sessions || {};
state.historyMeta = data.meta || { folders: [], uncategorized: [] };
// Hydrate collapsed-folder UI state from persisted server meta
state.collapsedFolders = new Set(
(state.historyMeta.folders || [])
.filter(f => f.collapsed)
.map(f => f.id)
);
state.historyLoaded = true;
} catch {
state.historySessions = {};
state.historyMeta = { folders: [], uncategorized: [] };
}
}
async function loadSession(id) {
try {
const res = await fetch(`${API_BASE}/api/history/${id}`);
const data = await res.json();
state.currentSessionId = id;
state.videoId = data.videoId;
state.videoTitle = data.title || "";
state.url = data.url || "";
state.chunks = data.chunks || [];
state.logs = data.logs || [];
state.currentType = data.type || "youtube";
state.expandedChunks = new Set();
state.expandAll = false;
state.loading = false;
state.error = null;
state.videoMinimized = false;
// On mobile, close sidebar after selection; on desktop, keep it open
if (window.innerWidth <= 900) state.historyOpen = false;
ytCurrentVideoId = null; // force fresh player for loaded session
render();
} catch (e) {
state.error = "Failed to load session: " + e.message;
render();
}
}
function startEditSessionTitle(id) {
state.editingSessionTitle = id;
render();
setTimeout(() => {
const el = document.getElementById("session-title-edit-" + id);
if (el) { el.focus(); el.select(); }
}, 50);
}
async function renameSession(id, newTitle) {
state.editingSessionTitle = null;
const session = state.historySessions[id];
if (session && newTitle.trim()) {
session.title = newTitle.trim();
}
render();
try {
await fetch(`${API_BASE}/api/history/${id}/title`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle.trim() }),
});
} catch {}
}
async function deleteSession(id, ev) {
ev.stopPropagation();
try {
// Animate the item out
const el = ev.target.closest(".history-item");
if (el) {
el.classList.add("removing");
}
// Fire the delete request
fetch(`${API_BASE}/api/history/${id}`, { method: "DELETE" }).catch(() => {});
// Wait for animation, then update state
await new Promise(r => setTimeout(r, 300));
delete state.historySessions[id];
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== id);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== id);
// Surgically update just the sidebar instead of full re-render
const sidebar = document.querySelector(".history-sidebar");
if (sidebar && state.historyOpen) {
// Capture current scroll so the user stays where they were
const prevList = sidebar.querySelector(".history-list");
const prevScroll = prevList ? prevList.scrollTop : 0;
const sidebarHtml = renderHistorySidebar();
const temp = document.createElement("div");
temp.innerHTML = sidebarHtml;
// Replace sidebar content (keep the element to avoid re-animation)
const newSidebar = temp.querySelector(".history-sidebar");
if (newSidebar) sidebar.innerHTML = newSidebar.innerHTML;
// Restore scroll position on the freshly created list
const newList = sidebar.querySelector(".history-list");
if (newList && prevScroll > 0) newList.scrollTop = prevScroll;
} else {
render();
}
} catch {}
}
async function createFolder() {
try {
const res = await fetch(`${API_BASE}/api/history/folders`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Folder" }),
});
const folder = await res.json();
state.historyMeta.folders.push(folder);
state.editingFolder = folder.id;
render();
// Focus the input
setTimeout(() => { const el = document.getElementById("folder-edit-" + folder.id); if (el) { el.focus(); el.select(); } }, 50);
} catch {}
}
async function renameFolder(id, name) {
state.editingFolder = null;
const folder = state.historyMeta.folders.find(f => f.id === id);
if (folder) folder.name = name || folder.name;
render();
try {
await fetch(`${API_BASE}/api/history/folders/${id}`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name }),
});
} catch {}
}
async function deleteFolder(id, ev) {
ev.stopPropagation();
try {
await fetch(`${API_BASE}/api/history/folders/${id}`, { method: "DELETE" });
const idx = state.historyMeta.folders.findIndex(f => f.id === id);
if (idx !== -1) {
const [folder] = state.historyMeta.folders.splice(idx, 1);
state.historyMeta.uncategorized = [...folder.items, ...state.historyMeta.uncategorized];
}
render();
} catch {}
}
function toggleFolder(id) {
const nowCollapsed = !state.collapsedFolders.has(id);
if (nowCollapsed) state.collapsedFolders.add(id);
else state.collapsedFolders.delete(id);
// Mirror into local meta so re-renders stay consistent without a refetch
const folder = state.historyMeta.folders.find(f => f.id === id);
if (folder) folder.collapsed = nowCollapsed;
render();
// Persist to server (fire-and-forget; UI already updated optimistically)
fetch(`${API_BASE}/api/history/folders/${id}/collapsed`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ collapsed: nowCollapsed }),
}).catch(() => {});
}
// Drag & drop with insertion indicator
let dragDropTarget = null; // { sessionId, position: "above"|"below", folderId }
function onDragStart(ev, sessionId) {
state.draggingId = sessionId;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", sessionId);
// Use timeout so the dragging class applies after the drag image is captured
setTimeout(() => ev.target.classList.add("dragging"), 0);
}
function onDragEnd(ev) {
state.draggingId = null;
dragDropTarget = null;
document.querySelectorAll(".dragging, .drop-above, .drop-below, .drag-over").forEach(
el => el.classList.remove("dragging", "drop-above", "drop-below", "drag-over")
);
}
function onItemDragOver(ev) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
const item = ev.currentTarget;
const rect = item.getBoundingClientRect();
const midY = rect.top + rect.height / 2;
const position = ev.clientY < midY ? "above" : "below";
// Clear all indicators
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
// Set indicator on this item
item.classList.add(position === "above" ? "drop-above" : "drop-below");
// Track target for drop
const targetId = item.dataset.sessionId;
const folderId = item.dataset.folderId || null;
dragDropTarget = { targetId, position, folderId };
}
function onItemDragLeave(ev) {
const item = ev.currentTarget;
// Only remove if actually leaving (not entering a child)
if (!item.contains(ev.relatedTarget)) {
item.classList.remove("drop-above", "drop-below");
}
}
async function onItemDrop(ev) {
ev.preventDefault();
ev.stopPropagation();
document.querySelectorAll(".drop-above, .drop-below").forEach(
el => el.classList.remove("drop-above", "drop-below")
);
const sessionId = state.draggingId;
if (!sessionId || !dragDropTarget) return;
if (sessionId === dragDropTarget.targetId) return; // Dropped on self
const { targetId, position, folderId } = dragDropTarget;
// Determine the target list and insertion index
let targetList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
targetList = folder ? folder.items : null;
} else {
targetList = state.historyMeta.uncategorized;
}
if (!targetList) return;
// Find where the target is and compute insertion index
const targetIdx = targetList.indexOf(targetId);
if (targetIdx === -1) return;
const insertIdx = position === "below" ? targetIdx + 1 : targetIdx;
// Remove from all lists first
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
// Re-resolve target list after removal (indices may have shifted)
let finalList;
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
finalList = folder ? folder.items : null;
} else {
finalList = state.historyMeta.uncategorized;
}
if (!finalList) return;
// Compute the final index (target may have shifted if dragged item was before it)
const finalTargetIdx = finalList.indexOf(targetId);
const finalInsertIdx = position === "below" ? finalTargetIdx + 1 : finalTargetIdx;
finalList.splice(finalInsertIdx, 0, sessionId);
state.draggingId = null;
dragDropTarget = null;
render();
// Persist to server
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId, index: finalInsertIdx }),
});
} catch {}
}
// Folder header drop (move into a folder, appended at end)
async function onDropToFolder(ev, folderId) {
ev.preventDefault();
ev.currentTarget.classList.remove("drag-over");
const sessionId = state.draggingId;
if (!sessionId) return;
state.historyMeta.uncategorized = state.historyMeta.uncategorized.filter(i => i !== sessionId);
for (const f of state.historyMeta.folders) f.items = f.items.filter(i => i !== sessionId);
if (folderId) {
const folder = state.historyMeta.folders.find(f => f.id === folderId);
if (folder) folder.items.push(sessionId);
} else {
state.historyMeta.uncategorized.push(sessionId);
}
state.draggingId = null;
dragDropTarget = null;
render();
try {
await fetch(`${API_BASE}/api/history/move`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sessionId, folderId }),
});
} catch {}
}
function onListDragOver(ev) { ev.preventDefault(); ev.dataTransfer.dropEffect = "move"; }
async function onDropToUncategorized(ev) { await onDropToFolder(ev, null); }
function formatUploadDate(yyyymmdd) {
// yt-dlp returns YYYYMMDD, e.g. "20260207"
if (!yyyymmdd || yyyymmdd.length !== 8) return "";
const y = yyyymmdd.slice(0, 4);
const m = parseInt(yyyymmdd.slice(4, 6), 10);
const d = parseInt(yyyymmdd.slice(6, 8), 10);
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
return `${months[m - 1]} ${d}, ${y}`;
}
function timeAgo(dateStr) {
const diff = Date.now() - new Date(dateStr).getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return "Just now";
if (mins < 60) return mins + "m ago";
const hrs = Math.floor(mins / 60);
if (hrs < 24) return hrs + "h ago";
const days = Math.floor(hrs / 24);
if (days < 7) return days + "d ago";
return new Date(dateStr).toLocaleDateString();
}
function renderHistoryItem(id, folderId) {
const h = state.historySessions[id];
if (!h) return "";
const isEditing = state.editingSessionTitle === h.id;
const folderAttr = folderId ? `data-folder-id="${folderId}"` : "";
return `
<div class="history-item ${state.videoId === h.videoId ? "active" : ""}"
draggable="${isEditing ? "false" : "true"}"
data-session-id="${h.id}" ${folderAttr}
ondragstart="onDragStart(event, '${h.id}')"
ondragend="onDragEnd(event)"
ondragover="onItemDragOver(event)"
ondragleave="onItemDragLeave(event)"
ondrop="onItemDrop(event)"
onclick="${isEditing ? "" : "loadSession('" + h.id + "')"}">
<div class="history-thumb">
${h.type === "podcast"
? `<div style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#1e293b;border-radius:6px;font-size:20px;">🎙</div>`
: `<img src="https://img.youtube.com/vi/${h.videoId}/default.jpg" alt="" loading="lazy" />`}
</div>
<div class="history-info">
${isEditing
? `<input class="history-title-input" id="session-title-edit-${h.id}"
value="${escHtml(h.title)}"
onclick="event.stopPropagation()"
onblur="renameSession('${h.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingSessionTitle=null;render()}" />`
: `<div class="history-title" title="${escHtml(h.title)} (double-click to rename)"
ondblclick="event.stopPropagation(); startEditSessionTitle('${h.id}')">${escHtml(h.title)}</div>`
}
<div class="history-meta">${h.uploadDate ? formatUploadDate(h.uploadDate) : timeAgo(h.createdAt)} &middot; ${h.topicCount} topics</div>
</div>
<button class="history-action-small" onclick="event.stopPropagation(); exportSessionPDF('${h.id}')" title="Export PDF">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
</button>
<button class="history-delete" onclick="deleteSession('${h.id}', event)" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
function renderHistorySidebar() {
const totalSessions = Object.keys(state.historySessions).length;
const { folders, uncategorized } = state.historyMeta;
const historyEntitled = hasEntitlement("history");
return `
<div class="history-sidebar-overlay" onclick="toggleHistory()"></div>
<div class="history-sidebar ${historyAnimateIn ? "animate-in" : ""}">
<div class="history-header">
<h2>Library</h2>
<div class="history-actions">
${historyEntitled ? `
<button class="history-action-btn" onclick="createFolder()" title="New Folder">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line><line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
</button>
` : ""}
<button class="close-btn" onclick="toggleHistory()" style="width:30px;height:30px;font-size:16px">&times;</button>
</div>
</div>
${!historyEntitled ? `
<div style="padding: 16px;">
${renderProUpsell("Summary library", "Save, organize, and revisit every summary you generate. Folders, drag-and-drop, search. Available on the Pro tier.")}
</div>
` : `
<div class="history-list"
ondragover="onListDragOver(event)"
ondrop="onDropToUncategorized(event)">
${totalSessions === 0
? `<div class="history-empty">No sessions yet. Summarize a video to see it here.</div>`
: `
${folders.map(folder => {
const isCollapsed = state.collapsedFolders.has(folder.id);
const isEditing = state.editingFolder === folder.id;
return `
<div class="history-folder">
<div class="history-folder-header" onclick="toggleFolder('${folder.id}')"
ondragover="event.preventDefault(); event.dataTransfer.dropEffect='move'; event.currentTarget.classList.add('drag-over')"
ondragleave="event.currentTarget.classList.remove('drag-over')"
ondrop="event.currentTarget.classList.remove('drag-over'); onDropToFolder(event, '${folder.id}')">
<span class="folder-arrow ${isCollapsed ? "" : "open"}">\u25B6</span>
<span class="folder-icon">\uD83D\uDCC1</span>
${isEditing
? `<input class="folder-name-input" id="folder-edit-${folder.id}"
value="${escHtml(folder.name)}"
onclick="event.stopPropagation()"
onblur="renameFolder('${folder.id}', this.value)"
onkeydown="if(event.key==='Enter')this.blur(); if(event.key==='Escape'){state.editingFolder=null;render()}" />`
: `<span class="folder-name">${escHtml(folder.name)}</span>`
}
<span class="folder-count">${folder.items.length}</span>
<span class="folder-actions">
<button class="folder-action" onclick="event.stopPropagation(); state.editingFolder='${folder.id}'; render(); setTimeout(()=>{const el=document.getElementById('folder-edit-${folder.id}');if(el){el.focus();el.select()}},50)" title="Rename">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>
</button>
<button class="folder-action danger" onclick="deleteFolder('${folder.id}', event)" title="Delete folder">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</span>
</div>
<div class="folder-items ${isCollapsed ? "collapsed" : ""}">
${folder.items.map(id => renderHistoryItem(id, folder.id)).join("")}
</div>
</div>
`;
}).join("")}
${uncategorized.length > 0 && folders.length > 0 ? `<div class="history-section-label">Unsorted</div>` : ""}
${uncategorized.map(id => renderHistoryItem(id)).join("")}
`
}
</div>
`}
</div>
`;
}
function toggleShowKey() { state.showKey = !state.showKey; render(); }
function setApiKey(v) {
state.apiKey = v;
localStorage.setItem("yt-summarizer-gemini-key", v);
}
function setModel(m) { state.model = m; render(); }
function toggleExpandAll() {
state.expandAll = !state.expandAll;
if (!state.expandAll) state.expandedChunks.clear();
render();
}
function toggleChunk(i) {
const wasExpanded = state.expandedChunks.has(i);
// Collapse all others (accordion behavior)
state.expandedChunks.clear();
state.expandAll = false;
// Toggle the clicked one
if (!wasExpanded) state.expandedChunks.add(i);
render();
// Scroll the expanded chunk to the top of the scroll area
if (!wasExpanded) {
setTimeout(() => {
const chunkEl = document.getElementById("chunk-" + i);
const scrollEl = document.querySelector(".chunks-scroll");
if (chunkEl && scrollEl) {
chunkEl.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, 60);
}
}
function toggleClipPanel() {
// Pro-tier feature: silently no-op if the license lacks the entitlement.
// Existing clipCollection in localStorage is preserved across upgrades.
if (!hasEntitlement("clips")) return;
state.clipPanelOpen = !state.clipPanelOpen;
render();
}
function renderClipPanel() {
const clips = state.clipCollection;
const grouped = {};
clips.forEach((clip, idx) => {
if (!grouped[clip.sessionId]) grouped[clip.sessionId] = [];
grouped[clip.sessionId].push({ ...clip, _idx: idx });
});
let clipListHtml = "";
if (clips.length === 0) {
clipListHtml = '<div style="text-align:center; padding:32px 16px; color:#475569; font-size:13px;">' +
'<p style="margin-bottom:8px;">No clips collected yet.</p>' +
'<p style="font-size:12px;">Click 📎 on any topic or transcript line to add it here.<br>' +
'You can collect clips from multiple videos to build a curated export.</p></div>';
} else {
for (const [sessionId, sessionClips] of Object.entries(grouped)) {
const session = state.historySessions[sessionId];
const sessionTitle = session ? escHtml(session.title) : "Unknown Video";
clipListHtml += '<div style="margin-bottom:16px;">';
clipListHtml += '<div style="font-size:13px; font-weight:600; color:#e2e8f0; margin-bottom:8px; padding-bottom:6px; border-bottom:1px solid #1e293b;">' + sessionTitle + '</div>';
for (const clip of sessionClips) {
const label = clip.entryIndex !== null
? "Topic " + (clip.chunkIndex + 1) + ", line " + (clip.entryIndex + 1)
: "Topic " + (clip.chunkIndex + 1) + " (full)";
const noteHtml = clip.note
? '<div style="font-size:11px; color:#818cf8; font-style:italic; margin-top:2px; padding-left:2px;">' +
'💬 ' + escHtml(clip.note) + '</div>'
: '';
clipListHtml += '<div style="padding:6px 8px; border-radius:6px; margin-bottom:4px;" onmouseover="this.style.background=\'rgba(129,140,248,0.06)\'" onmouseout="this.style.background=\'none\'">' +
'<div style="display:flex; align-items:center; gap:8px;">' +
'<span style="flex:1; font-size:12px; color:#94a3b8;">' + escHtml(label) + '</span>' +
'<button style="border:none; background:none; color:#64748b; cursor:pointer; font-size:11px; padding:2px 6px; border-radius:4px;" onclick="editClipNote(' + clip._idx + ')" title="Edit note">✏️</button>' +
'<button style="border:none; background:none; color:#475569; cursor:pointer; font-size:14px; padding:2px 6px; border-radius:4px;" onclick="removeFromClipCollection(' + clip._idx + ')" title="Remove">&times;</button>' +
'</div>' +
noteHtml +
'</div>';
}
clipListHtml += '</div>';
}
}
return `
<div class="settings-overlay" onclick="toggleClipPanel()">
<div class="settings-modal" onclick="event.stopPropagation()" style="max-width: 560px;">
<div class="settings-modal-header">
<h2>📎 Clip Collection (${clips.length})</h2>
<div style="display:flex; gap:8px;">
${clips.length > 0 ? `
<button class="expand-btn" onclick="exportClipCollectionPDF()" style="font-size:11px; padding:5px 12px;">Export PDF</button>
<button class="expand-btn" onclick="clearClipCollection()" style="font-size:11px; padding:5px 12px; color:#f87171;">Clear All</button>
` : ""}
<button class="close-btn" onclick="toggleClipPanel()">&times;</button>
</div>
</div>
<div class="settings-modal-body" style="max-height: 60vh; overflow-y: auto;">
${clipListHtml}
</div>
</div>
</div>
`;
}
// ── PDF Export & Clip Collection ────────────────────────────────────────
async function exportSessionPDF(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
if (!data.chunks || data.chunks.length === 0) { showToast("No data to export", "⚠", 3000); return; }
buildPDF(data.title || "Untitled", data.videoId, data.chunks, data.type || "youtube");
} catch (e) {
showToast("Export failed: " + e.message, "✕", 4000);
}
}
function exportCurrentPDF() {
if (!state.chunks.length) return;
buildPDF(state.videoTitle || "Untitled", state.videoId, state.chunks, state.currentType);
}
// ── Shared PDF helpers ──────────────────────────────────────────────
function createPDFDoc() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pw = doc.internal.pageSize.getWidth();
const ph = doc.internal.pageSize.getHeight();
const margin = 20;
const maxW = pw - margin * 2;
let y = margin;
let pageNum = 1;
// Colors
const C = {
title: [15, 23, 42], // near-black
heading: [30, 41, 59], // dark slate
body: [51, 65, 85], // medium slate
meta: [100, 116, 139], // gray
light: [148, 163, 184], // light gray
link: [37, 99, 235], // blue
accent: [99, 102, 241], // indigo
noteBg: [248, 250, 252], // very light gray
noteBar: [99, 102, 241], // indigo bar
divider: [226, 232, 240], // light border
};
function addFooter() {
doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(...C.light);
doc.text("YouTube Transcript Summarizer", margin, ph - 8);
doc.text("Page " + pageNum, pw - margin, ph - 8, { align: "right" });
}
function checkPage(needed) {
if (y + needed > ph - 16) {
addFooter();
doc.addPage(); y = margin; pageNum++;
return true;
}
return false;
}
// Draw underlined link text
function drawLink(text, x, yPos, url) {
doc.setTextColor(...C.link);
doc.textWithLink(text, x, yPos, { url });
// Underline
const tw = doc.getTextWidth(text);
doc.setDrawColor(...C.link); doc.setLineWidth(0.2);
doc.line(x, yPos + 0.8, x + tw, yPos + 0.8);
}
// Draw a shaded note box with left accent bar
function drawNoteBox(noteText, x, boxWidth) {
const noteLines = doc.splitTextToSize(noteText, boxWidth - 14);
const lineH = 4;
const boxH = noteLines.length * lineH + 8;
checkPage(boxH + 4);
// Background fill
doc.setFillColor(...C.noteBg);
doc.roundedRect(x, y - 2, boxWidth, boxH, 2, 2, "F");
// Left accent bar
doc.setFillColor(...C.noteBar);
doc.rect(x, y - 2, 1.5, boxH, "F");
// Note label
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
doc.setTextColor(...C.accent);
doc.text("MY NOTE", x + 6, y + 2);
// Note text
doc.setFont("helvetica", "normal"); doc.setFontSize(9);
doc.setTextColor(...C.heading);
let ny = y + 6;
noteLines.forEach(line => { doc.text(line, x + 6, ny); ny += lineH; });
y += boxH + 3;
}
return { doc, pw, ph, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox, getY: () => y, setY: (v) => { y = v; }, addY: (v) => { y += v; } };
}
function buildPDF(title, videoId, chunks, type) {
if (!window.jspdf) {
showToast("PDF library not loaded yet — please try again in a moment", "⚠", 4000);
return;
}
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(20); doc.setTextColor(...C.title);
const titleLines = doc.splitTextToSize(title, maxW);
titleLines.forEach(line => { checkPage(9); doc.text(line, margin, p.getY()); p.addY(8); });
p.addY(2);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(chunks.length + " topics | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line under header
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(8);
// ── Chunks ──
chunks.forEach((chunk, ci) => {
checkPage(22);
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + startSec : null;
// Topic number pill
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("TOPIC " + (ci + 1), margin, p.getY());
// Time range
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, margin + 22, p.getY());
p.addY(5);
// Topic title
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...C.heading);
const titleL = doc.splitTextToSize(chunk.title, maxW);
titleL.forEach((line, li) => {
checkPage(6);
if (li === 0 && ytUrl) {
drawLink(line, margin, p.getY(), ytUrl);
} else {
doc.text(line, margin, p.getY());
}
p.addY(5.5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9.5); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, maxW);
sumLines.forEach(line => { checkPage(5); doc.text(line, margin, p.getY()); p.addY(4.2); });
p.addY(3);
// Transcript entries
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
chunk.entries.forEach(entry => {
checkPage(5);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + sec : null;
// Timestamp
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", margin + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
// Text after timestamp
const textLines = doc.splitTextToSize(entry.text, maxW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, margin + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, margin + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, maxW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, margin + 2, p.getY()); p.addY(3.6); });
}
});
p.addY(4);
// Separator
if (ci < chunks.length - 1) {
checkPage(6);
doc.setDrawColor(...C.divider); doc.setLineWidth(0.15);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
}
});
addFooter();
const safeName = title.replace(/[^a-zA-Z0-9_\- ]/g, "").trim().substring(0, 60) || "transcript";
doc.save(safeName + ".pdf");
showToast("PDF exported", "📄");
} catch (err) {
console.error("PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
// ── Clip Collection ───────────────────────────────────────────────────────
function addToClipCollection(sessionId, chunkIndex, entryIndex) {
// Pro-tier feature. Defense-in-depth: even if a Core user reaches this
// (e.g. via stale UI before re-render), refuse the add and prompt upgrade.
if (!hasEntitlement("clips")) {
showToast("Clips are a Pro feature. Upgrade to unlock.", "🔒", 3500);
return;
}
// Check if already in collection
const exists = state.clipCollection.find(c =>
c.sessionId === sessionId && c.chunkIndex === chunkIndex &&
(entryIndex === undefined ? c.entryIndex === null : c.entryIndex === entryIndex)
);
if (exists) {
showToast("Already in clip collection", "", 2000);
return;
}
// Show notes prompt modal
showClipNotePrompt(sessionId, chunkIndex, entryIndex !== undefined ? entryIndex : null);
}
function showClipNotePrompt(sessionId, chunkIndex, entryIndex) {
// Create overlay
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>📎 Add Clip</h2>' +
'<button class="close-btn" id="clip-note-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<label class="field-label" style="margin-top:0">Note (optional)</label>' +
'<textarea id="clip-note-input" rows="3" placeholder="Why is this interesting? Add context for when you share it..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;"></textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-note-skip" class="expand-btn" style="font-size:12px; padding:8px 16px;">Skip</button>' +
'<button id="clip-note-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Add Clip</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-note-input");
setTimeout(() => input.focus(), 50);
function finish(note) {
state.clipCollection.push({
sessionId,
chunkIndex,
entryIndex,
note: (note || "").trim() || null,
});
saveClipCollection();
overlay.remove();
showToast("Clip added (" + state.clipCollection.length + " total)", "📎");
render();
}
function cancel() { overlay.remove(); }
document.getElementById("clip-note-save").onclick = () => finish(input.value);
document.getElementById("clip-note-skip").onclick = () => finish("");
document.getElementById("clip-note-cancel").onclick = cancel;
overlay.onclick = cancel;
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finish(input.value); } };
}
function editClipNote(index) {
const clip = state.clipCollection[index];
if (!clip) return;
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>✏️ Edit Note</h2>' +
'<button class="close-btn" id="clip-edit-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<textarea id="clip-edit-input" rows="3" placeholder="Add your thoughts about this clip..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;">' +
escHtml(clip.note || "") + '</textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-edit-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-edit-input");
setTimeout(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 50);
function save() {
state.clipCollection[index].note = input.value.trim() || null;
saveClipCollection();
overlay.remove();
render();
}
document.getElementById("clip-edit-save").onclick = save;
document.getElementById("clip-edit-cancel").onclick = () => overlay.remove();
overlay.onclick = () => overlay.remove();
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } };
}
function removeFromClipCollection(index) {
state.clipCollection.splice(index, 1);
saveClipCollection();
render();
}
function clearClipCollection() {
state.clipCollection = [];
state.clipPanelOpen = false;
saveClipCollection();
render();
showToast("Clip collection cleared", "🗑");
}
function saveClipCollection() {
localStorage.setItem("yt-summarizer-clips", JSON.stringify(state.clipCollection));
}
function loadClipCollection() {
try {
const saved = localStorage.getItem("yt-summarizer-clips");
if (saved) state.clipCollection = JSON.parse(saved);
} catch {}
}
async function exportClipCollectionPDF() {
if (state.clipCollection.length === 0) { showToast("No clips collected", "⚠", 3000); return; }
if (!window.jspdf) { showToast("PDF library not loaded yet — please try again", "⚠", 4000); return; }
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(22); doc.setTextColor(...C.title);
doc.text("Curated Clips", margin, p.getY()); p.addY(9);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(state.clipCollection.length + " clips | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(10);
// Fetch all needed sessions
const sessionIds = [...new Set(state.clipCollection.map(c => c.sessionId))];
const sessionData = {};
for (const sid of sessionIds) {
try {
const res = await fetch(API_BASE + "/api/history/" + sid);
sessionData[sid] = await res.json();
} catch { sessionData[sid] = null; }
}
// ── Render clips ──
let currentSessionId = null;
let clipNum = 0;
for (const clip of state.clipCollection) {
const session = sessionData[clip.sessionId];
if (!session) continue;
const chunk = session.chunks?.[clip.chunkIndex];
if (!chunk) continue;
clipNum++;
// Session header if new session
if (clip.sessionId !== currentSessionId) {
checkPage(18);
if (currentSessionId !== null) { p.addY(6); }
// Session divider with title
doc.setDrawColor(...C.divider); doc.setLineWidth(0.3);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text("SOURCE", margin, p.getY()); p.addY(4);
doc.setFont("helvetica", "bold"); doc.setFontSize(14); doc.setTextColor(...C.title);
const stitleLines = doc.splitTextToSize(session.title || "Untitled", maxW);
stitleLines.forEach(line => { checkPage(7); doc.text(line, margin, p.getY()); p.addY(6); });
p.addY(4);
currentSessionId = clip.sessionId;
}
// ── User note (shaded box with left accent bar) ──
if (clip.note) {
drawNoteBox(clip.note, margin, maxW);
}
// ── Clip content (indented under the note) ──
const indent = clip.note ? 4 : 0; // indent content when there's a note
const contentX = margin + indent;
const contentW = maxW - indent;
checkPage(14);
const isYt = (session.type || "youtube") === "youtube";
const vid = session.videoId;
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + startSec : null;
// Topic label + time
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("CLIP " + clipNum, contentX, p.getY());
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, contentX + 18, p.getY());
p.addY(5);
// Topic title with link
doc.setFont("helvetica", "bold"); doc.setFontSize(11); doc.setTextColor(...C.heading);
const ctLines = doc.splitTextToSize(chunk.title, contentW);
ctLines.forEach((line, li) => {
checkPage(5.5);
if (li === 0 && ytUrl) {
drawLink(line, contentX, p.getY(), ytUrl);
} else {
doc.text(line, contentX, p.getY());
}
p.addY(5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, contentW);
sumLines.forEach(line => { checkPage(4.5); doc.text(line, contentX, p.getY()); p.addY(4); });
p.addY(2);
// Transcript entries
const entries = clip.entryIndex !== null ? [chunk.entries[clip.entryIndex]].filter(Boolean) : chunk.entries;
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
entries.forEach(entry => {
checkPage(4);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (isYt && vid) ? "https://youtube.com/watch?v=" + vid + "&t=" + sec : null;
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", contentX + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
const textLines = doc.splitTextToSize(entry.text, contentW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, contentX + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, contentX + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, contentW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, contentX + 2, p.getY()); p.addY(3.6); });
}
});
p.addY(6);
// Light separator between clips
doc.setDrawColor(...C.divider); doc.setLineWidth(0.1);
const sepX1 = margin + 20; const sepX2 = pw - margin - 20;
doc.line(sepX1, p.getY(), sepX2, p.getY()); p.addY(6);
}
addFooter();
doc.save("curated-clips.pdf");
showToast("Clips PDF exported", "📄");
} catch (err) {
console.error("Clip PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
function escHtml(s) {
if (!s) return "";
return s.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Keysat license helpers ───────────────────────────────────────────────
function hasEntitlement(name) {
return !!(state.license && state.license.entitlements && state.license.entitlements.includes(name));
}
function isLicensed() {
return state.license && state.license.state === "licensed" && hasEntitlement("core");
}
function isProTier() {
// Pro tier is defined by the entitlements that distinguish it from Core,
// i.e. subscriptions + clips. (history + library are now Core, so they
// don't separate the tiers anymore.)
return isLicensed() && hasEntitlement("subscriptions") && hasEntitlement("clips");
}
async function loadLicenseStatus() {
try {
const res = await fetch(`${API_BASE}/api/license-status`);
const data = await res.json();
state.license = {
loaded: true,
state: data.state || "unlicensed",
reason: data.reason || null,
licenseId: data.licenseId || null,
entitlements: data.entitlements || [],
expiresAt: data.expiresAt || null,
isTrial: !!data.isTrial,
productSlug: data.productSlug || "youtube-summarizer",
keysatBaseUrl: data.keysatBaseUrl || "",
};
} catch {
state.license = {
...state.license,
loaded: true,
state: "unlicensed",
reason: "license_status_unreachable",
};
}
}
async function activateLicense() {
const key = (state.licenseActivationKey || "").trim();
if (!key) {
state.licenseActivationError = "Paste a license key first.";
render();
return;
}
state.licenseActivating = true;
state.licenseActivationError = null;
render();
try {
const res = await fetch(`${API_BASE}/api/license/activate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ license_key: key }),
});
const data = await res.json();
if (res.ok && data.ok) {
state.license = {
loaded: true,
state: data.state,
reason: data.reason,
licenseId: data.licenseId,
entitlements: data.entitlements || [],
expiresAt: data.expiresAt,
isTrial: !!data.isTrial,
productSlug: data.productSlug || "youtube-summarizer",
keysatBaseUrl: data.keysatBaseUrl || "",
};
state.licenseActivationKey = "";
state.licenseActivationError = null;
showToast("License activated.", "✓");
// Now that we're licensed, kick off the loads we deferred at boot.
await loadAfterLicensed();
} else {
state.licenseActivationError = data.message || data.reason || "Activation failed.";
}
} catch (e) {
state.licenseActivationError = "Could not reach the server.";
} finally {
state.licenseActivating = false;
render();
}
}
async function deactivateLicense() {
if (!confirm("Remove the license from this server? You'll need to paste the key again to re-activate.")) return;
try {
await fetch(`${API_BASE}/api/license/deactivate`, { method: "POST" });
} catch {}
await loadLicenseStatus();
// Clear in-memory data that the deactivated user can no longer see.
state.subscriptions = [];
state.subsLoaded = false;
state.historySessions = {};
state.historyMeta = { folders: [], uncategorized: [] };
state.historyLoaded = false;
render();
}
async function loadAfterLicensed() {
// Lightweight version of init's secondary loads. Only fetches what
// the current entitlements actually permit.
await loadHistory().catch(() => {});
if (hasEntitlement("subscriptions")) {
await loadSubscriptions().catch(() => {});
try { await pollAutoQueue(); } catch {}
}
}
function upgradeToProUrl() {
const base = state.license.keysatBaseUrl || "https://licensing.keysat.xyz";
return `${base.replace(/\/$/, "")}/buy/${state.license.productSlug || "youtube-summarizer"}`;
}
function showToast(message, icon = "✓", duration = 4000) {
const container = document.getElementById("toast-container");
if (!container) return;
const toast = document.createElement("div");
toast.className = "toast";
toast.innerHTML = `<span class="toast-icon">${icon}</span><span class="toast-msg">${escHtml(message)}</span>`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add("fade-out");
setTimeout(() => toast.remove(), 300);
}, duration);
}
// ── Init ─────────────────────────────────────────────────────────────────
// Load persisted clip collection
loadClipCollection();
// Fetch license-status + health + network mode in parallel. License is
// load-bearing: when unlicensed, the activation overlay replaces the app
// entirely and we skip the loads that would 402 anyway (history,
// subscriptions, auto-queue).
Promise.all([
loadLicenseStatus(),
fetch(`${API_BASE}/api/health`).then(r => r.json()),
fetch(`${API_BASE}/api/network-mode`).then(r => r.json()).catch(() => null),
]).then(async ([_, health, net]) => {
state.hasServerKey = !!health.hasServerKey;
if (!health.installed) {
state.ytdlpVersion = false;
state.error = "Backend is running but yt-dlp is not installed.\nInstall it with: brew install yt-dlp (or) pip install yt-dlp";
} else {
state.ytdlpVersion = health.version;
state.ytdlpLatest = health.latestVersion;
state.ytdlpUpdateAvailable = health.updateAvailable || false;
}
// Cookie status
if (health.cookies) {
state.cookieMethod = health.cookies.method || "none";
state.cookieFileAgeDays = health.cookies.fileAgeDays;
state.cookieFileExpiring = health.cookies.fileExpiring || false;
}
if (net) state.lanMode = !!net.lan;
state.serverStatus = "connected";
// Only load licensed-only data when the activation gate would let us.
if (isLicensed()) {
if (hasEntitlement("history")) await loadHistory().catch(() => {});
if (hasEntitlement("subscriptions")) {
await loadSubscriptions().catch(() => {});
try {
const added = await pollAutoQueue();
if (added) render();
} catch {}
startBgPoll();
}
}
render();
}).catch(() => {
state.serverStatus = "disconnected";
state.error = "Cannot connect to backend at localhost:3001.\nMake sure the server is running: cd server && npm install && npm start";
render();
});
// ── Heartbeat: ping server every 10s so it knows we're here ──
let heartbeatFailCount = 0;
async function sendHeartbeat() {
try {
const res = await fetch(`${API_BASE}/api/heartbeat`, { method: "POST" });
const data = await res.json();
heartbeatFailCount = 0;
const newStatus = data.sleeping ? "sleeping" : "connected";
if (state.serverStatus !== newStatus) {
state.serverStatus = newStatus;
updateStatusBadge();
}
} catch {
heartbeatFailCount++;
if (heartbeatFailCount >= 2 && state.serverStatus !== "disconnected") {
state.serverStatus = "disconnected";
updateStatusBadge();
}
}
}
// Update just the status badge without a full re-render (avoids player disruption)
function updateStatusBadge() {
const el = document.querySelector(".server-status");
if (el) {
el.outerHTML = renderServerStatus();
}
}
setInterval(sendHeartbeat, 10_000);
// Send heartbeat on page visibility change (coming back from another tab)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") {
sendHeartbeat();
}
});
render();
</script>
</body>
</html>