25b1c3a366
Unlicensed users can now summarize a single video at a time using their
own Gemini API key. The result renders in the UI exactly like a paid
summary, but is not persisted — there's no library entry, no history,
and a second submission while one is in flight is rejected.
Server (server/index.js):
• /api/process is now in LICENSE_OPEN_PATHS. The route handler
distinguishes free users (state !== "licensed" || no "core") and:
- rejects USE_SERVER_KEY / empty key with 402 byo_key_required
(so the bundled Gemini key stays paid-only)
- rejects a second concurrent job with 409 processing_in_progress
via a module-level freeJobInFlight flag, released in finally
- skips saveToHistory so the host's library stays clean
• Pro feature gates (history/library/subscriptions) unchanged — still
return 402 feature_not_in_tier for unlicensed callers.
Frontend (public/index.html):
• New state.activationSkipped flag (persisted to localStorage). The
activation screen still appears on first launch, but now offers a
"Skip — use free mode" button alongside Activate / Buy a key.
Once skipped, the main app renders normally.
• Free-mode upgrade banner under the top bar with Upgrade and "I have
a key" buttons (the latter routes back to the activation screen).
• handleLibraryClick / handleSubscribeClick wrappers — for unlicensed
users, the library (clock) icon and the channel-URL Subscribe
submission show a toast explaining the upgrade rather than opening
an empty sidebar / hitting a 402.
• Submit button enforces BYO key for unlicensed users (the bundled
state.hasServerKey doesn't enable submit). handleSubmit shows a
toast when an unlicensed user tries to queue a second video.
4260 lines
197 KiB
HTML
4260 lines
197 KiB
HTML
<!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: "",
|
||
// Free tier: once dismissed, the activation screen no longer
|
||
// hard-gates the UI. Persisted so returning unlicensed users land
|
||
// straight in the app.
|
||
activationSkipped: localStorage.getItem("yt-summarizer-activation-skipped") === "1",
|
||
};
|
||
|
||
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() {
|
||
// Free tier requires BYO Gemini key — bundled key is licensed-only.
|
||
const free = !isLicensed();
|
||
const hasKey = free ? !!state.apiKey.trim() : (state.apiKey.trim() || state.hasServerKey);
|
||
if (!state.url.trim() || !hasKey) {
|
||
if (free && !state.apiKey.trim() && state.url.trim()) {
|
||
showToast("Free mode needs your own Gemini API key — open Settings to enter one.", "🔑");
|
||
}
|
||
return;
|
||
}
|
||
|
||
const url = state.url.trim();
|
||
|
||
// If already processing — free tier blocks, paid tier queues.
|
||
if (state.loading) {
|
||
if (free) {
|
||
showToast("Free mode handles one video at a time. Wait for the current one to finish.", "⏳");
|
||
return;
|
||
}
|
||
state.url = "";
|
||
state.queue.push({ id: Date.now().toString(), url, status: "queued", error: null });
|
||
render();
|
||
return;
|
||
}
|
||
|
||
state.url = "";
|
||
await processUrl(url);
|
||
}
|
||
|
||
function handleLibraryClick() {
|
||
// Library / history is a paid feature (history entitlement). For
|
||
// free-tier users, surface the upgrade prompt instead of opening an
|
||
// empty sidebar.
|
||
if (!hasEntitlement("history")) {
|
||
showToast("Library is a paid feature — keep every summary you process. Tap upgrade to unlock.", "📚");
|
||
return;
|
||
}
|
||
toggleHistory();
|
||
}
|
||
|
||
function handleSubscribeClick() {
|
||
// Subscriptions / channel auto-queue is Pro-only.
|
||
if (!hasEntitlement("subscriptions")) {
|
||
showToast("Channel & podcast subscriptions are a Pro feature. Upgrade to auto-summarize new uploads.", "📡");
|
||
return;
|
||
}
|
||
addSubscriptionFromInput();
|
||
}
|
||
|
||
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>YouTube Summarizer</h1>
|
||
<p class="activation-sub">
|
||
${loading
|
||
? "Checking license…"
|
||
: "Activate a Keysat license to unlock the full app — library, subscriptions, channel auto-queue, and the bundled API key. Or skip to use free mode (one video at a time, bring your own Gemini key)."
|
||
}
|
||
</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 →</a>
|
||
<button class="activation-link"
|
||
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
|
||
onclick="dismissActivation()">
|
||
Skip — use free mode
|
||
</button>
|
||
</div>
|
||
<div class="activation-meta">
|
||
Product: <strong>${escHtml(lic.productSlug || "youtube-summarizer")}</strong>
|
||
${lic.keysatBaseUrl ? ` · Issuer: <strong>${escHtml(lic.keysatBaseUrl.replace(/^https?:\/\//, ""))}</strong>` : ""}
|
||
</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function dismissActivation() {
|
||
state.activationSkipped = true;
|
||
try { localStorage.setItem("yt-summarizer-activation-skipped", "1"); } catch {}
|
||
render();
|
||
}
|
||
|
||
function renderFreeBanner() {
|
||
const buyUrl = upgradeToProUrl();
|
||
return `
|
||
<div class="free-banner" style="
|
||
margin: 8px 0 12px;
|
||
padding: 10px 14px;
|
||
background: linear-gradient(90deg, rgba(168,85,247,0.12), rgba(99,102,241,0.10));
|
||
border: 1px solid rgba(168,85,247,0.35);
|
||
border-radius: 10px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
color: #e2e8f0;
|
||
font-size: 13px;
|
||
">
|
||
<span style="flex:1; min-width: 220px;">
|
||
<strong style="color:#c4b5fd;">Free mode</strong>
|
||
· one video at a time, bring your own Gemini key ·
|
||
no library, no subscriptions
|
||
</span>
|
||
<a href="${escHtml(buyUrl)}" target="_blank" rel="noopener"
|
||
style="background:#a855f7;color:#fff;border:none;padding:6px 12px;border-radius:6px;text-decoration:none;font-size:12px;font-weight:600;">
|
||
Upgrade
|
||
</a>
|
||
<button onclick="showActivationScreen()"
|
||
style="background:transparent;color:#94a3b8;border:1px solid #334155;padding:6px 10px;border-radius:6px;cursor:pointer;font-size:12px;">
|
||
I have a key
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function showActivationScreen() {
|
||
// Take user back to the activation modal (e.g. from an upgrade banner).
|
||
state.activationSkipped = false;
|
||
try { localStorage.removeItem("yt-summarizer-activation-skipped"); } catch {}
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
// 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;
|
||
}
|
||
// Show the activation screen on first launch for unlicensed users so
|
||
// they discover the upgrade path. Once they hit "Skip — use free mode"
|
||
// (which sets activationSkipped = true) they fall through to the
|
||
// normal app, which renders an upgrade banner instead.
|
||
if (!isLicensed() && !state.activationSkipped) {
|
||
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;
|
||
const free = !isLicensed();
|
||
const submitNeedsBYO = free; // bundled key is licensed-only
|
||
const submitDisabled = !state.url.trim()
|
||
|| (!isSubscribeUrl(state.url)
|
||
&& (submitNeedsBYO
|
||
? !state.apiKey.trim()
|
||
: (!state.apiKey.trim() && !state.hasServerKey)));
|
||
app.innerHTML = `
|
||
<!-- Top bar: title + action icons -->
|
||
<div class="top-bar">
|
||
<div class="top-left-actions">
|
||
<button class="icon-btn ${state.historyOpen && !free ? "active" : ""}" onclick="handleLibraryClick()"
|
||
title="${free ? "Library (upgrade to unlock)" : "Library"}"
|
||
style="${free ? "opacity:0.55;" : ""}">
|
||
<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 ? (free ? "Wait for the current video — free mode is one at a time" : "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) ? handleSubscribeClick() : handleSubmit() }" />
|
||
<button class="submit-btn"
|
||
onclick="${isSubscribeUrl(state.url) ? "handleSubscribeClick()" : "handleSubmit()"}"
|
||
${submitDisabled ? "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() : ""}
|
||
|
||
${free ? renderFreeBanner() : ""}
|
||
|
||
${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)} · Pro feature</div>
|
||
<div class="pro-desc">${escHtml(description)}</div>
|
||
<a class="pro-cta" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener">Upgrade to Pro →</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()">×</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 · ${totalEntries} segments · ${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 · ${totalEntries} segments · ${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()">×</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)} · ${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">×</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">×</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()">×</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">×</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">×</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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||
}
|
||
|
||
// ── 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>
|