373d10595b
Captures roughly forty version bumps (v0.2.6 → v0.2.47) of work that
accumulated without commits.
- Pluggable provider system under server/providers/: gemini, anthropic,
openai, openai-compatible, ollama, whisper-compatible, relay. Mix and
match transcription + analysis per request via the picker UI.
- Relay backend integration. Hardcoded relay URL in server/relay-default.js
(operator-controlled at build time, not user-configurable). New
/api/relay/{status,policy} endpoints proxy to the relay; balance pings
populate a cached credit display.
- Per-install identity in server/install-id.js for relay credit accounting.
Sent to the relay as X-Recap-Install-Id; persists across upgrades, lost
on a full uninstall + reinstall. Not surfaced in the UI.
- Admin login gate (server/admin-auth.js + setAdminPassword action). Scrypt
password hash + HMAC-signed session cookie.
- Entitlement scheme rename: pro / max (each paired with subscriptions and
relay_pro / relay_max), replacing the misleading "core" entitlement
that conflicted with the user-facing "Core" tier name.
- Activation screen: dynamic credit count pulled from /api/relay/policy,
"Skip — use free mode" button, accurate paid-feature list.
- Top toolbar: inline credit-balance pill (or "BYO configured" fallback),
Upgrade + "I have a key" buttons.
- Picker UI: per-provider sections with Save/Test/Delete buttons, sections
collapsible by chevron, default-collapsed unless currently selected,
"Use comped credits (reset to relay)" link when the user has strayed,
green hint under inputs whose values are server-configured.
- Activity log: chevron-collapsible groups per video, refresh-survival via
localStorage + a 500-entry server-side buffer, explicit Clear button.
- YouTube captions fast-path with user toggle (skips audio download + AI
transcription when captions are available — uncheck for speaker labels).
- Cancel button: AbortController plumbed through every provider SDK call;
retryAPI short-circuits on AbortError; cancellation events surface in
the activity log instead of silent retries.
- Long-video analysis: auto-coalesce transcript entries before building the
analysis prompt so local-model context windows (32k-ish) don't overflow.
Original entries preserved for transcript display via an index map; the
analyzer sees a coarser view but click-to-seek timestamps stay precise.
- StartOS action grouping (Setup / AI Providers) so the actions list is
navigable.
- Manifest description rewritten to reflect multi-provider support and
free-tier relay credits.
- Smaller fixes: summarize-button enablement no longer requires a Gemini
key when other providers are configured; analysis fallback chain handles
context-length and 503 capacity errors; single-segment expansion for
providers that don't return per-segment timestamps (Parakeet et al.);
many other UX polish items.
5947 lines
278 KiB
HTML
5947 lines
278 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||
<title>Recap</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;
|
||
}
|
||
|
||
/* Inline status + upgrade slot between the URL input and the right
|
||
icons. Wraps on narrow screens; hidden on mobile alongside
|
||
.top-actions. */
|
||
.top-bar-status {
|
||
display: flex; align-items: center; gap: 6px; margin-right: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.top-bar-status .status-pill {
|
||
font-size: 11px; font-weight: 600; padding: 6px 10px;
|
||
border-radius: 8px; border: 1px solid transparent;
|
||
white-space: nowrap;
|
||
}
|
||
.top-bar-status .upgrade-btn {
|
||
background: #a855f7; color: #fff; border: none;
|
||
padding: 7px 12px; border-radius: 8px; text-decoration: none;
|
||
font-size: 11px; font-weight: 700; cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.top-bar-status .upgrade-btn:hover { background: #c084fc; }
|
||
.top-bar-status .have-key-btn {
|
||
background: transparent; color: #94a3b8;
|
||
border: 1px solid #334155; padding: 6px 10px; border-radius: 8px;
|
||
cursor: pointer; font-size: 11px; font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.top-bar-status .have-key-btn:hover { color: #cbd5e1; border-color: #475569; }
|
||
|
||
/* 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);
|
||
}
|
||
|
||
// ── Provider catalog ─────────────────────────────────────────────────────
|
||
// Static metadata about each backend Recap can talk to. Drives the
|
||
// picker UI: which providers appear in each dropdown, which models
|
||
// each lists by default, and which key/URL fields the Settings panel
|
||
// shows. Server-side adapter behavior is independently authoritative
|
||
// — this catalog is purely UX scaffolding.
|
||
const PROVIDERS = [
|
||
{
|
||
id: "gemini",
|
||
name: "Google Gemini",
|
||
canTranscribe: true,
|
||
canAnalyze: true,
|
||
// Transcription uses Flash tier (best speed/cost on long audio).
|
||
// Older Flash generations are included so users on rate-limited
|
||
// or quota-restricted keys can fall back manually when 3-flash
|
||
// overloads. Order = newest first; the server's fallback chain
|
||
// walks the same list automatically if a 503 is returned.
|
||
transcriptionModels: [
|
||
"gemini-3-flash-preview",
|
||
"gemini-2.5-flash",
|
||
"gemini-2.0-flash",
|
||
],
|
||
analysisModels: [
|
||
"gemini-3.1-pro-preview",
|
||
"gemini-3-pro-preview",
|
||
"gemini-3-flash-preview",
|
||
"gemini-2.5-flash",
|
||
],
|
||
keyField: { key: "apiKey", label: "Gemini API Key", placeholder: "AIza...", masked: true, helpUrl: "https://aistudio.google.com/apikey" },
|
||
},
|
||
{
|
||
id: "anthropic",
|
||
name: "Anthropic (Claude)",
|
||
canTranscribe: false,
|
||
canAnalyze: true,
|
||
transcriptionModels: [],
|
||
analysisModels: [
|
||
"claude-opus-4-7",
|
||
"claude-opus-4-6",
|
||
"claude-sonnet-4-6",
|
||
"claude-haiku-4-5",
|
||
],
|
||
keyField: { key: "apiKey", label: "Anthropic API Key", placeholder: "sk-ant-...", masked: true, helpUrl: "https://console.anthropic.com" },
|
||
},
|
||
{
|
||
id: "openai",
|
||
name: "OpenAI",
|
||
canTranscribe: true,
|
||
canAnalyze: true,
|
||
transcriptionModels: ["whisper-1"],
|
||
analysisModels: ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "o3-mini"],
|
||
keyField: { key: "apiKey", label: "OpenAI API Key", placeholder: "sk-...", masked: true, helpUrl: "https://platform.openai.com/api-keys" },
|
||
},
|
||
{
|
||
id: "whisper",
|
||
name: "OpenAI/Whisper-Compatible Endpoint",
|
||
canTranscribe: true,
|
||
canAnalyze: false,
|
||
// No fixed catalog — different Whisper backends use different
|
||
// model names (whisper-1 / whisper-large-v3 / base.en / etc.),
|
||
// so users define their model list in the credentials block.
|
||
transcriptionModels: [],
|
||
analysisModels: [],
|
||
urlField: { key: "baseURL", label: "Base URL", placeholder: "http://whisper.startos:8000" },
|
||
keyField: { key: "apiKey", label: "API Key (optional)", placeholder: "(leave blank if self-hosted with no auth)", masked: true },
|
||
modelsField: {
|
||
key: "models",
|
||
label: "Models",
|
||
placeholder: "whisper-1, whisper-large-v3",
|
||
hint: "Comma-separated. Common values: whisper-1 (OpenAI standard), whisper-large-v3 (whisper.cpp / faster-whisper / Groq).",
|
||
},
|
||
},
|
||
{
|
||
id: "openai-compatible",
|
||
name: "OpenAI-Compatible (DeepSeek, Together, Groq, …)",
|
||
canTranscribe: false,
|
||
canAnalyze: true,
|
||
transcriptionModels: [],
|
||
// No hardcoded catalog — the modelsField in credentials lets
|
||
// users define their model list, which then surfaces as the
|
||
// picker dropdown options. If the list is empty the picker
|
||
// falls back to a free-text input.
|
||
analysisModels: [],
|
||
analysisModelDefault: "deepseek-chat",
|
||
keyField: { key: "apiKey", label: "API Key", placeholder: "sk-...", masked: true },
|
||
urlField: { key: "baseURL", label: "Base URL", placeholder: "https://api.deepseek.com/v1" },
|
||
modelsField: {
|
||
key: "models",
|
||
label: "Models",
|
||
placeholder: "deepseek-chat, deepseek-reasoner",
|
||
hint: "Comma-separated. These appear in the model dropdown above.",
|
||
},
|
||
},
|
||
{
|
||
id: "ollama",
|
||
name: "Ollama (local)",
|
||
canTranscribe: false,
|
||
canAnalyze: true,
|
||
transcriptionModels: [],
|
||
analysisModels: [],
|
||
analysisModelDefault: "llama3.1",
|
||
urlField: { key: "baseURL", label: "Ollama Base URL", placeholder: "http://localhost:11434" },
|
||
modelsField: {
|
||
key: "models",
|
||
label: "Models",
|
||
placeholder: "llama3.1, mistral, qwen2",
|
||
hint: "Comma-separated. Installed models are auto-detected from your Ollama server, so this is only needed if you want a subset.",
|
||
},
|
||
},
|
||
{
|
||
// Operator-side relay. Recap doesn't pick a model — relay
|
||
// chooses internally based on tier + cost. No credentials UI
|
||
// because the install-id + license proof are auto-attached
|
||
// server-side; users don't configure anything here.
|
||
id: "relay",
|
||
name: "Relay (comped credits)",
|
||
canTranscribe: true,
|
||
canAnalyze: true,
|
||
transcriptionModels: ["relay-default"],
|
||
analysisModels: ["relay-default"],
|
||
// No keyField / urlField / modelsField — the relay's baseURL
|
||
// is operator-configured server-side via the "Set Relay URL"
|
||
// StartOS action, and identity is auto-managed.
|
||
},
|
||
];
|
||
const PROVIDER_BY_ID = Object.fromEntries(PROVIDERS.map((p) => [p.id, p]));
|
||
const TRANSCRIBE_PROVIDERS = PROVIDERS.filter((p) => p.canTranscribe);
|
||
const ANALYZE_PROVIDERS = PROVIDERS.filter((p) => p.canAnalyze);
|
||
|
||
// ── Provider opts persistence ───────────────────────────────────────────
|
||
// Shape: { gemini: {apiKey}, anthropic: {apiKey}, openai: {apiKey},
|
||
// "openai-compatible": {apiKey, baseURL}, ollama: {baseURL} }.
|
||
// Migrates the legacy single-key localStorage entry the first time
|
||
// it's seen so users with a saved Gemini key don't lose it.
|
||
function loadProviderOpts() {
|
||
let opts = {};
|
||
try {
|
||
const raw = localStorage.getItem("recap-provider-opts");
|
||
if (raw) opts = JSON.parse(raw) || {};
|
||
} catch {}
|
||
const legacyGemini = localStorage.getItem("recap-gemini-key") || "";
|
||
if (legacyGemini && !opts.gemini?.apiKey) {
|
||
opts.gemini = { ...(opts.gemini || {}), apiKey: legacyGemini };
|
||
}
|
||
// Ensure every provider has an entry so accesses like
|
||
// state.providerOpts.anthropic.apiKey don't error.
|
||
for (const p of PROVIDERS) {
|
||
if (!opts[p.id]) opts[p.id] = {};
|
||
}
|
||
return opts;
|
||
}
|
||
|
||
function saveProviderOpts() {
|
||
try {
|
||
localStorage.setItem("recap-provider-opts", JSON.stringify(state.providerOpts));
|
||
} catch {}
|
||
// Mirror the gemini key into the legacy storage slot so other code
|
||
// paths (and a future rollback) keep working.
|
||
const gk = state.providerOpts.gemini?.apiKey || "";
|
||
try {
|
||
if (gk) localStorage.setItem("recap-gemini-key", gk);
|
||
else localStorage.removeItem("recap-gemini-key");
|
||
} catch {}
|
||
// Keep the live `state.apiKey` synonym (Gemini key) in sync with
|
||
// the canonical providerOpts entry. Existing checks against
|
||
// state.apiKey continue to work without modification.
|
||
state.apiKey = gk;
|
||
}
|
||
|
||
function loadProviderSelection() {
|
||
let sel = {};
|
||
try {
|
||
const raw = localStorage.getItem("recap-providers");
|
||
if (raw) sel = JSON.parse(raw) || {};
|
||
} catch {}
|
||
// For fresh installs with no saved preference, default to the
|
||
// relay provider — that's the "works out of the box with no
|
||
// setup" choice. Anyone who'd rather use their own API key or
|
||
// a self-hosted model just picks a different provider from the
|
||
// dropdown; the new pick gets saved to localStorage so it
|
||
// sticks across sessions.
|
||
const desiredTrans = sel.transcriptionProvider || "relay";
|
||
const desiredAna = sel.analysisProvider || "relay";
|
||
const tp =
|
||
TRANSCRIBE_PROVIDERS.find((p) => p.id === desiredTrans) ||
|
||
TRANSCRIBE_PROVIDERS[0];
|
||
const ap =
|
||
ANALYZE_PROVIDERS.find((p) => p.id === desiredAna) ||
|
||
ANALYZE_PROVIDERS[0];
|
||
return {
|
||
transcriptionProvider: tp.id,
|
||
transcriptionModel: sel.transcriptionModel || tp.transcriptionModels[0] || "",
|
||
analysisProvider: ap.id,
|
||
analysisModel: sel.analysisModel || ap.analysisModels[0] || ap.analysisModelDefault || "",
|
||
};
|
||
}
|
||
|
||
// Hard reset of the picker selection back to relay/relay. Useful
|
||
// when the user has stale selections from a previous session
|
||
// (e.g. they configured Whisper at home and now want comped
|
||
// credits) and wants a one-click way back to the defaults.
|
||
function resetProvidersToRelay() {
|
||
state.transcriptionProvider = "relay";
|
||
state.transcriptionModel = "relay-default";
|
||
state.analysisProvider = "relay";
|
||
state.analysisModel = "relay-default";
|
||
saveProviderSelection();
|
||
render();
|
||
}
|
||
|
||
function saveProviderSelection() {
|
||
try {
|
||
localStorage.setItem("recap-providers", JSON.stringify({
|
||
transcriptionProvider: state.transcriptionProvider,
|
||
transcriptionModel: state.transcriptionModel,
|
||
analysisProvider: state.analysisProvider,
|
||
analysisModel: state.analysisModel,
|
||
}));
|
||
} catch {}
|
||
}
|
||
|
||
// ── Activity-log persistence ─────────────────────────────────────────────
|
||
// Logs are kept in localStorage so a browser refresh doesn't drop the
|
||
// user's record of what just happened (or what's still happening).
|
||
// Bounded at MAX_LOG_ENTRIES so a long-running browser session won't
|
||
// grow the localStorage payload without limit. Cleared explicitly via
|
||
// the "Clear" button in the activity-log drawer.
|
||
const LOG_STORAGE_KEY = "recap-activity-log-v1";
|
||
const MAX_LOG_ENTRIES = 2000;
|
||
|
||
function loadLogsFromStorage() {
|
||
try {
|
||
const raw = localStorage.getItem(LOG_STORAGE_KEY);
|
||
if (!raw) return [];
|
||
const arr = JSON.parse(raw);
|
||
return Array.isArray(arr) ? arr : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
function saveLogsToStorage() {
|
||
try {
|
||
if (state.logs.length > MAX_LOG_ENTRIES) {
|
||
state.logs.splice(0, state.logs.length - MAX_LOG_ENTRIES);
|
||
}
|
||
localStorage.setItem(LOG_STORAGE_KEY, JSON.stringify(state.logs));
|
||
} catch {}
|
||
}
|
||
|
||
// Single push site used by every log mutation in the app — keeps the
|
||
// localStorage mirror in sync without sprinkling save calls at six
|
||
// different push sites in handleSSE / processUrl / etc.
|
||
function pushLog(entry) {
|
||
state.logs.push(entry);
|
||
saveLogsToStorage();
|
||
}
|
||
|
||
function clearLogHistory() {
|
||
// Cheap confirm dialog — the action is destructive and there's no
|
||
// undo. Users typically clear once per long session, not constantly,
|
||
// so the extra click is unintrusive.
|
||
if (state.logs.length === 0) return;
|
||
if (!confirm(`Clear ${state.logs.length} activity-log entries? This can't be undone.`)) return;
|
||
state.logs = [];
|
||
state.collapsedLogGroups = new Set();
|
||
saveLogsToStorage();
|
||
render();
|
||
}
|
||
|
||
// ── State ────────────────────────────────────────────────────────────────
|
||
const state = {
|
||
url: "",
|
||
apiKey: localStorage.getItem("recap-gemini-key") || "",
|
||
hasServerKey: false, // will be set by health check
|
||
// Persistent per-install UUID minted by the server on first boot.
|
||
// Populated from /api/health. Shown in Settings → Install ID for
|
||
// verification; will be sent to the upcoming relay backend as
|
||
// the owner of comped/paid relay credits.
|
||
installId: null,
|
||
// Last-known relay credit balance + tier. Populated from
|
||
// /api/relay/status on boot and after every relay call.
|
||
// { creditsRemaining, tier, lastUpdated, lastError, configured }
|
||
// configured=false means the operator hasn't wired up a relay
|
||
// base URL yet — the picker still shows "Relay (comped credits)"
|
||
// but the option is disabled.
|
||
relayStatus: { creditsRemaining: null, tier: null, lastUpdated: null, lastError: null, configured: false },
|
||
// Live tier-quota policy from the relay, fetched on boot.
|
||
// Drives dynamic copy (e.g. activation screen's credit count
|
||
// updates without a Recap release when the operator tunes the
|
||
// Core lifetime cap via the relay's Adjust Tier Quotas action).
|
||
// Shape: { configured: bool, tiers: {...} | null, core_total_credits, core_gemini_credits, error? }
|
||
relayPolicy: { configured: false, tiers: null, core_total_credits: null, core_gemini_credits: null },
|
||
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: loadLogsFromStorage(),
|
||
logOpen: false,
|
||
// Set of separator-entry indices the user has collapsed. Each
|
||
// separator (── title ──) anchors one group of log entries that
|
||
// follow it until the next separator. Adding state here (rather
|
||
// than DOM-only) keeps collapse state stable across re-renders +
|
||
// refreshes.
|
||
collapsedLogGroups: new Set(),
|
||
// 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: "recap",
|
||
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("recap-activation-skipped") === "1",
|
||
// Admin login gate (set via the StartOS "Set Admin Password" action).
|
||
// When `enabled`, no API call works without a valid session cookie,
|
||
// and the login screen takes priority over the activation screen.
|
||
admin: {
|
||
loaded: false,
|
||
enabled: false,
|
||
authed: false,
|
||
username: null,
|
||
},
|
||
adminLoginUsername: "",
|
||
adminLoginPassword: "",
|
||
adminLoginError: null,
|
||
adminLoggingIn: false,
|
||
// Server-tracked in-flight job (free-tier only today). Populated
|
||
// on boot from /api/process/current and refreshed via polling, so
|
||
// the user still sees what's running after a browser refresh.
|
||
currentJob: null,
|
||
cancellingJob: false,
|
||
// Test-connection state per provider id. providerTesting flips
|
||
// true while a request is in flight; providerTestResults stores
|
||
// the most recent { ok, text|error, latencyMs }.
|
||
providerTesting: {},
|
||
providerTestResults: {},
|
||
// Whether each provider's per-field server config is populated.
|
||
// Shape: { [providerId]: { [fieldName]: bool } }. Populated from
|
||
// /api/providers/credentials-status on boot + after Save/Delete.
|
||
// Drives "✓ Server-configured" hints and whether the Delete
|
||
// button is shown when localStorage is empty.
|
||
providerServerStatus: {},
|
||
// YouTube captions fast-path toggle. When on (default), Recap
|
||
// uses YouTube's own captions when available and skips audio
|
||
// download + AI transcription entirely. Off forces a full
|
||
// transcription pass (better for speaker labels — captions
|
||
// don't have them).
|
||
useYouTubeCaptions: localStorage.getItem("recap-use-yt-captions") !== "0",
|
||
// Per-provider client-side opts (apiKey + baseURL where applicable).
|
||
// Sent verbatim in the request body as `providerOpts`. Server
|
||
// overlays them on top of values set via the StartOS actions.
|
||
providerOpts: loadProviderOpts(),
|
||
// Per-pipeline provider + model selection. Persisted so a user's
|
||
// mix-and-match (e.g. Gemini transcribe → Claude analyze) sticks
|
||
// across sessions.
|
||
...loadProviderSelection(),
|
||
};
|
||
|
||
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() {
|
||
// The selected transcription + analysis providers must each have
|
||
// a usable config. Relay counts when the relay URL is reachable
|
||
// (the operator-controlled hardcoded URL); other providers count
|
||
// when a key/URL is set in localStorage or in the StartOS config.
|
||
// See providerCanRun() for the per-provider rules.
|
||
if (!state.url.trim()) return;
|
||
if (!providersCanRun()) {
|
||
const tp = PROVIDER_BY_ID[state.transcriptionProvider]?.name || state.transcriptionProvider;
|
||
const ap = PROVIDER_BY_ID[state.analysisProvider]?.name || state.analysisProvider;
|
||
const missing = [];
|
||
if (!providerCanRun(state.transcriptionProvider)) missing.push(tp);
|
||
if (!providerCanRun(state.analysisProvider) && state.analysisProvider !== state.transcriptionProvider) missing.push(ap);
|
||
const what = missing.join(" + ");
|
||
showToast(
|
||
`${what} ${missing.length === 1 ? "isn't" : "aren't"} configured. ` +
|
||
`Add a key/endpoint in Settings (or use the operator's StartOS actions), ` +
|
||
`or pick Relay from the picker for comped credits.`,
|
||
"🔑"
|
||
);
|
||
return;
|
||
}
|
||
|
||
const url = state.url.trim();
|
||
|
||
// If already processing — free tier blocks (one at a time),
|
||
// paid tier queues for batch.
|
||
if (state.loading) {
|
||
if (!isLicensed()) {
|
||
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 is free for everyone — every summary gets saved.
|
||
toggleHistory();
|
||
}
|
||
|
||
function handleSubscribeClick() {
|
||
// Subscriptions / channel auto-queue is Pro-only.
|
||
if (!hasEntitlement("subscriptions")) {
|
||
showToast("Channel & podcast subscriptions are a Pro-tier feature.", "📡");
|
||
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...";
|
||
// Pre-populate the in-flight banner client-side so the user
|
||
// immediately sees what's running + the Cancel button. The server
|
||
// ground-truth gets pulled in by the poll loop shortly after.
|
||
state.currentJob = {
|
||
url,
|
||
title: opts.title || "",
|
||
startedAt: Date.now(),
|
||
elapsedMs: 0,
|
||
aborted: false,
|
||
};
|
||
startCurrentJobPoll();
|
||
state.settingsOpen = false;
|
||
state.expandedChunks = new Set();
|
||
// Push a separator for every video — the activity log persists
|
||
// across browser sessions, so users may already have entries from
|
||
// prior runs in state.logs even before any push this turn.
|
||
const title = opts.title || url;
|
||
pushLog({ elapsed: "—", message: `── ${title} ──`, detail: null, separator: true });
|
||
render();
|
||
|
||
try {
|
||
const body = {
|
||
url,
|
||
// Legacy single-key field — kept so older server builds keep
|
||
// working. New server resolves it as the gemini fallback.
|
||
apiKey: state.apiKey.trim() || "USE_SERVER_KEY",
|
||
model: state.model,
|
||
// Picker-UI fields: which provider + model handles each
|
||
// pipeline step, and per-provider client-side opts (apiKey,
|
||
// baseURL) the server overlays on top of its config.
|
||
transcriptionProvider: state.transcriptionProvider,
|
||
transcriptionModel: state.transcriptionModel,
|
||
analysisProvider: state.analysisProvider,
|
||
analysisModel: state.analysisModel,
|
||
providerOpts: state.providerOpts,
|
||
useYouTubeCaptions: state.useYouTubeCaptions,
|
||
};
|
||
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();
|
||
// If a free-tier job is already in flight, sync the local
|
||
// status banner to the server-reported job so the user
|
||
// immediately sees what's running + can cancel it.
|
||
if (err.error === "processing_in_progress" && err.currentJob) {
|
||
state.currentJob = err.currentJob;
|
||
render();
|
||
}
|
||
// Prefer the server's human-readable `message` over the
|
||
// short error code so users see "A summary is already being
|
||
// processed (…)" rather than "processing_in_progress".
|
||
throw new Error(err.message || 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;
|
||
// The server-tracked current-job slot is released by the server
|
||
// in its own finally; refresh our snapshot so the banner
|
||
// disappears immediately rather than waiting for the poll.
|
||
loadCurrentJob().finally(() => 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") {
|
||
pushLog({ 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());
|
||
// Refresh the relay balance — if this job went through the
|
||
// relay, the server-side cache was just updated by the
|
||
// transcribe/analyze responses, and we want the picker banner
|
||
// to reflect the new count.
|
||
loadRelayStatus().then(() => render()).catch(() => {});
|
||
} else if (event === "error") {
|
||
state.error = data.message;
|
||
pushLog({ elapsed: "---", message: "ERROR: " + data.message, error: true });
|
||
render();
|
||
} else if (event === "cancelled") {
|
||
// Server confirmed the cancellation went through. The fetch
|
||
// reader's finally clause clears state.loading and the banner
|
||
// poll catches the slot release a moment later; just log the
|
||
// acknowledgement here so the user sees "Cancelled by user"
|
||
// in the activity log.
|
||
pushLog({ elapsed: "---", message: data.message || "Cancelled by user" });
|
||
render();
|
||
}
|
||
}
|
||
|
||
// ── Render ───────────────────────────────────────────────────────────────
|
||
|
||
function renderAdminLoginScreen() {
|
||
// Reuses .activation-screen / .activation-card styling so the gate
|
||
// looks consistent with the activation screen that follows it.
|
||
return `
|
||
<div class="activation-screen">
|
||
<div class="activation-card">
|
||
<h1>Recap</h1>
|
||
<p class="activation-sub">Sign in to continue.</p>
|
||
<label class="activation-label">Username</label>
|
||
<input class="activation-key" type="text" autocomplete="username"
|
||
style="font-family:inherit;"
|
||
value="${escHtml(state.adminLoginUsername)}"
|
||
oninput="state.adminLoginUsername=this.value; document.getElementById('admin-login-btn').disabled = !canSubmitAdminLogin();"
|
||
onkeydown="if(event.key==='Enter'){document.getElementById('admin-login-password').focus();}" />
|
||
<label class="activation-label">Password</label>
|
||
<input class="activation-key" type="password" id="admin-login-password" autocomplete="current-password"
|
||
style="font-family:inherit;"
|
||
value="${escHtml(state.adminLoginPassword)}"
|
||
oninput="state.adminLoginPassword=this.value; document.getElementById('admin-login-btn').disabled = !canSubmitAdminLogin();"
|
||
onkeydown="if(event.key==='Enter' && canSubmitAdminLogin()){submitAdminLogin();}" />
|
||
${state.adminLoginError ? `<div class="activation-error">${escHtml(state.adminLoginError)}</div>` : ""}
|
||
<div class="activation-actions">
|
||
<button id="admin-login-btn" class="activation-btn"
|
||
${!canSubmitAdminLogin() ? "disabled" : ""}
|
||
onclick="submitAdminLogin()">
|
||
${state.adminLoggingIn ? "Signing in…" : "Sign in"}
|
||
</button>
|
||
</div>
|
||
<div class="activation-meta">
|
||
The admin password is set on the server via the StartOS
|
||
<strong>Set Admin Password</strong> action.
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function canSubmitAdminLogin() {
|
||
return !!(state.adminLoginUsername.trim() && state.adminLoginPassword && !state.adminLoggingIn);
|
||
}
|
||
|
||
async function loadAdminStatus() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/admin/status`, { credentials: "same-origin" });
|
||
const data = await res.json();
|
||
state.admin = {
|
||
loaded: true,
|
||
enabled: !!data.enabled,
|
||
authed: !!data.authed,
|
||
username: data.username || null,
|
||
};
|
||
if (state.admin.enabled && state.adminLoginUsername === "" && data.username) {
|
||
state.adminLoginUsername = data.username;
|
||
}
|
||
} catch {
|
||
state.admin = {
|
||
loaded: true,
|
||
enabled: false,
|
||
authed: true,
|
||
username: null,
|
||
};
|
||
}
|
||
}
|
||
|
||
async function submitAdminLogin() {
|
||
if (!canSubmitAdminLogin()) return;
|
||
state.adminLoggingIn = true;
|
||
state.adminLoginError = null;
|
||
render();
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/admin/login`, {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
username: state.adminLoginUsername.trim(),
|
||
password: state.adminLoginPassword,
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({}));
|
||
if (res.ok && data.ok) {
|
||
state.adminLoginPassword = "";
|
||
state.admin.authed = true;
|
||
state.admin.enabled = !!data.enabled;
|
||
state.admin.username = data.username || state.admin.username;
|
||
// Now that we're authed, kick off the loads that were skipped
|
||
// while gated.
|
||
await initAfterAdminAuth();
|
||
} else {
|
||
state.adminLoginError = data.message || "Sign-in failed.";
|
||
}
|
||
} catch {
|
||
state.adminLoginError = "Could not reach the server.";
|
||
} finally {
|
||
state.adminLoggingIn = false;
|
||
render();
|
||
}
|
||
}
|
||
|
||
async function submitAdminLogout() {
|
||
try {
|
||
await fetch(`${API_BASE}/api/admin/logout`, { method: "POST", credentials: "same-origin" });
|
||
} catch {}
|
||
state.admin.authed = false;
|
||
state.adminLoginPassword = "";
|
||
state.adminLoginError = null;
|
||
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>Recap</h1>
|
||
<p class="activation-sub">
|
||
${loading
|
||
? "Checking license…"
|
||
: (() => {
|
||
// Pull the credit count live from the relay so this
|
||
// line stays accurate when the operator tunes the
|
||
// Core lifetime cap without redeploying Recap.
|
||
// Falls back to "free" wording if the relay is
|
||
// unreachable on first paint.
|
||
const credits = state.relayPolicy?.core_total_credits;
|
||
const creditsText =
|
||
typeof credits === "number" && credits > 0
|
||
? `${credits} relay credit${credits === 1 ? "" : "s"}`
|
||
: "free relay credits";
|
||
return `Activate a Recap license to unlock channel & podcast subscriptions and auto-queue. Or skip to use free mode — ${creditsText} to process recaps of videos and podcasts on us, plus unlimited recaps when you bring your own AI provider keys or self-hosted model URL.`;
|
||
})()
|
||
}
|
||
</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 || "recap")}</strong>
|
||
${lic.keysatBaseUrl ? ` · Issuer: <strong>${escHtml(lic.keysatBaseUrl.replace(/^https?:\/\//, ""))}</strong>` : ""}
|
||
</div>
|
||
`}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function dismissActivation() {
|
||
state.activationSkipped = true;
|
||
try { localStorage.setItem("recap-activation-skipped", "1"); } catch {}
|
||
render();
|
||
}
|
||
|
||
// True for anyone who isn't on the top tier — drives whether to show
|
||
// the persistent upgrade banner. Covers: unlicensed, unlicensed-skipped,
|
||
// Core (full or partial), and any "licensed but missing entitlements"
|
||
// anomaly. Pro users see no banner.
|
||
function shouldShowUpgradeBanner() {
|
||
if (!state.license || !state.license.loaded) return false;
|
||
return !isProTier();
|
||
}
|
||
|
||
// ── In-flight job banner ─────────────────────────────────────────────────
|
||
// Shown when the server reports an active free-tier job (server-tracked
|
||
// so a browser refresh doesn't hide it). Includes a Cancel button that
|
||
// hits /api/process/cancel.
|
||
function renderCurrentJobBanner() {
|
||
if (!state.currentJob) return "";
|
||
const job = state.currentJob;
|
||
const what = job.title || job.url || "a video";
|
||
const elapsedStr = formatInflightElapsed(job);
|
||
const aborted = job.aborted || state.cancellingJob;
|
||
return `
|
||
<div style="
|
||
margin: 8px 0 12px;
|
||
padding: 10px 14px;
|
||
background: linear-gradient(90deg, rgba(59,130,246,0.12), rgba(99,102,241,0.10));
|
||
border: 1px solid rgba(99,102,241,0.4);
|
||
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:#93c5fd;">${aborted ? "Cancelling…" : "Processing"}</strong>
|
||
· ${escHtml(what)}
|
||
<span style="color:#94a3b8;">· <span class="inflight-elapsed">${elapsedStr}</span> elapsed</span>
|
||
</span>
|
||
<button onclick="cancelCurrentJob()"
|
||
${aborted ? "disabled" : ""}
|
||
style="background:${aborted ? "#1e293b" : "#dc2626"};color:#fff;border:none;padding:6px 12px;border-radius:6px;cursor:${aborted ? "default" : "pointer"};font-size:12px;font-weight:600;${aborted ? "opacity:0.6;" : ""}">
|
||
${aborted ? "Cancelling" : "Cancel"}
|
||
</button>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Server-discovered per-provider connection defaults (today: the
|
||
// StartOS-hosted Ollama URL + list of installed Ollama models, when
|
||
// Recap and Ollama are co-installed). Used as placeholder/default
|
||
// in the picker UI when the user hasn't typed a value — fields
|
||
// remain editable.
|
||
let __providerDiscovery = {};
|
||
async function loadProviderDiscovery() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/providers/discover`, { credentials: "same-origin" });
|
||
if (!res.ok) return;
|
||
__providerDiscovery = (await res.json()) || {};
|
||
} catch {}
|
||
}
|
||
|
||
// Pull the relay's current tier-quota policy so activation copy
|
||
// (and any other dynamic credit-count text) reflects whatever the
|
||
// operator has the relay configured for, without needing a Recap
|
||
// update each time the policy changes. Best-effort: if the relay
|
||
// is unreachable, state.relayPolicy keeps its defaults and the
|
||
// copy falls back to a hardcoded number.
|
||
async function loadRelayPolicy() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/relay/policy`, {
|
||
credentials: "same-origin",
|
||
});
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
state.relayPolicy = {
|
||
configured: !!data.configured,
|
||
tiers: data.tiers || null,
|
||
core_total_credits: data.core_total_credits ?? null,
|
||
core_gemini_credits: data.core_gemini_credits ?? null,
|
||
error: data.error || null,
|
||
};
|
||
} catch {}
|
||
}
|
||
|
||
// Per-provider, per-field boolean indicating whether the server's
|
||
// startos-config.json has a value for that slot. Booleans only —
|
||
// never receives the actual key, so screenshots stay safe. Loaded
|
||
// on boot and refreshed after every Save / Delete so the picker
|
||
// UI's "✓ Server-configured" hints and Delete-button visibility
|
||
// stay in sync with what the StartOS actions have written.
|
||
async function loadProviderServerStatus() {
|
||
try {
|
||
const res = await fetch(
|
||
`${API_BASE}/api/providers/credentials-status`,
|
||
{ credentials: "same-origin" }
|
||
);
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
state.providerServerStatus = data.status || {};
|
||
} catch {}
|
||
}
|
||
|
||
// Pull the last-known relay credit balance + tier from the server's
|
||
// in-process cache. Cheap (no relay round-trip — server returns
|
||
// whatever it cached from the most recent /relay/* call). UI polls
|
||
// this on boot and after each successful summarize so the balance
|
||
// banner stays in sync without an extra relay hit.
|
||
async function loadRelayStatus() {
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/relay/status`, { credentials: "same-origin" });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
state.relayStatus = {
|
||
creditsRemaining: data.creditsRemaining ?? null,
|
||
tier: data.tier || null,
|
||
lastUpdated: data.lastUpdated || null,
|
||
lastError: data.lastError || null,
|
||
configured: !!data.configured,
|
||
};
|
||
} catch {}
|
||
}
|
||
function discoveredUrlFor(providerId) {
|
||
const entry = __providerDiscovery[providerId];
|
||
return (entry && entry.baseURL) || "";
|
||
}
|
||
function discoveredModelsFor(providerId) {
|
||
const entry = __providerDiscovery[providerId];
|
||
return (entry && Array.isArray(entry.models)) ? entry.models : [];
|
||
}
|
||
|
||
// Parse the user-supplied "Models" credentials field (a free-text
|
||
// string the user types in Settings) into a deduplicated array of
|
||
// trimmed model names. Accepts comma- and newline-separated input.
|
||
function parseUserModels(raw) {
|
||
if (!raw || typeof raw !== "string") return [];
|
||
const seen = new Set();
|
||
return raw
|
||
.split(/[,\n]/)
|
||
.map((s) => s.trim())
|
||
.filter((s) => {
|
||
if (!s) return false;
|
||
if (seen.has(s)) return false;
|
||
seen.add(s);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// Resolved list of analysis models for a provider, in priority
|
||
// order: user-defined (from credentials) → server-discovered →
|
||
// catalog default. Used by the picker dropdown.
|
||
function resolvedAnalysisModelsFor(provider) {
|
||
if (provider.analysisModels && provider.analysisModels.length > 0) {
|
||
return provider.analysisModels;
|
||
}
|
||
const userList = parseUserModels(state.providerOpts[provider.id]?.models);
|
||
if (userList.length > 0) return userList;
|
||
const discovered = discoveredModelsFor(provider.id);
|
||
if (discovered.length > 0) return discovered;
|
||
return [];
|
||
}
|
||
|
||
// Same idea but for transcription. Whisper-compatible / future
|
||
// transcription providers with dynamic catalogs (no fixed model
|
||
// list) use the user's Models field as their picker source.
|
||
function resolvedTranscriptionModelsFor(provider) {
|
||
if (provider.transcriptionModels && provider.transcriptionModels.length > 0) {
|
||
return provider.transcriptionModels;
|
||
}
|
||
const userList = parseUserModels(state.providerOpts[provider.id]?.models);
|
||
if (userList.length > 0) return userList;
|
||
const discovered = discoveredModelsFor(provider.id);
|
||
if (discovered.length > 0) return discovered;
|
||
return [];
|
||
}
|
||
|
||
function formatInflightElapsed(job) {
|
||
if (!job) return "";
|
||
const startedAt = job.startedAt || (Date.now() - (job.elapsedMs || 0));
|
||
const elapsedSec = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||
return elapsedSec >= 60
|
||
? `${Math.floor(elapsedSec / 60)}m ${elapsedSec % 60}s`
|
||
: `${elapsedSec}s`;
|
||
}
|
||
|
||
// Surgical update of just the elapsed-time text inside the
|
||
// in-flight banner. Lets the poll loop keep the counter ticking
|
||
// without re-rendering the whole app (which would wipe the
|
||
// activity-log sidebar, YouTube embed iframe, etc.).
|
||
function updateInflightElapsedDOM() {
|
||
const el = document.querySelector(".inflight-elapsed");
|
||
if (el && state.currentJob) {
|
||
el.textContent = formatInflightElapsed(state.currentJob);
|
||
}
|
||
}
|
||
|
||
async function loadCurrentJob({ withLogs = false } = {}) {
|
||
try {
|
||
const url = withLogs
|
||
? `${API_BASE}/api/process/current?logs=1`
|
||
: `${API_BASE}/api/process/current`;
|
||
const res = await fetch(url, { credentials: "same-origin" });
|
||
if (!res.ok) return;
|
||
const data = await res.json();
|
||
state.currentJob = data.job || null;
|
||
// Rehydrate activity log from server buffer (boot path only).
|
||
// Logs in localStorage already cover entries the client saw
|
||
// before the refresh — merge in only the server-side entries
|
||
// that aren't already present so the user picks up everything
|
||
// emitted after their SSE stream dropped. Dedup key is
|
||
// elapsed+message which is unique within a single job.
|
||
if (withLogs && state.currentJob && Array.isArray(data.job.logs)) {
|
||
const title = state.currentJob.title || state.currentJob.url || "Processing…";
|
||
const sepMsg = `── ${title} ──`;
|
||
const hasSeparator = state.logs.some(
|
||
(l) => l.separator && l.message === sepMsg
|
||
);
|
||
if (!hasSeparator) {
|
||
state.logs.push({ elapsed: "—", message: sepMsg, detail: null, separator: true });
|
||
}
|
||
const seen = new Set(state.logs.map((l) => `${l.elapsed}|${l.message}`));
|
||
let added = 0;
|
||
for (const e of data.job.logs) {
|
||
const key = `${e.elapsed}|${e.message}`;
|
||
if (!seen.has(key)) {
|
||
state.logs.push(e);
|
||
seen.add(key);
|
||
added++;
|
||
}
|
||
}
|
||
if (!hasSeparator || added > 0) saveLogsToStorage();
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
async function cancelCurrentJob() {
|
||
if (!state.currentJob || state.cancellingJob) return;
|
||
state.cancellingJob = true;
|
||
render();
|
||
try {
|
||
await fetch(`${API_BASE}/api/process/cancel`, { method: "POST", credentials: "same-origin" });
|
||
} catch {}
|
||
// The pipeline polls for the abort flag and bails at the next
|
||
// checkpoint — give it ~2s, then refresh the banner state.
|
||
setTimeout(() => {
|
||
loadCurrentJob().finally(() => {
|
||
state.cancellingJob = false;
|
||
render();
|
||
});
|
||
}, 2000);
|
||
}
|
||
|
||
// Poll loop: while a job is in flight, ping the server every 5s to
|
||
// keep the banner accurate (elapsed time + did-it-finish detection).
|
||
// Stops itself when the job disappears.
|
||
let __currentJobPollTimer = null;
|
||
let __inflightTickTimer = null;
|
||
function startCurrentJobPoll() {
|
||
if (!__currentJobPollTimer) {
|
||
__currentJobPollTimer = setInterval(async () => {
|
||
const prevHas = !!state.currentJob;
|
||
const prevAborted = !!(state.currentJob && state.currentJob.aborted);
|
||
await loadCurrentJob();
|
||
const has = !!state.currentJob;
|
||
const aborted = !!(state.currentJob && state.currentJob.aborted);
|
||
// Only do a full render() on presence/state transitions
|
||
// (job appears, disappears, or its aborted flag flips).
|
||
// The elapsed counter ticks via updateInflightElapsedDOM()
|
||
// — a surgical text-node update that leaves the rest of
|
||
// the DOM (activity log, YouTube embed, results panel)
|
||
// intact between polls.
|
||
const transitioned = prevHas !== has || prevAborted !== aborted;
|
||
if (transitioned) {
|
||
render();
|
||
} else if (has) {
|
||
updateInflightElapsedDOM();
|
||
}
|
||
if (!has) {
|
||
clearInterval(__currentJobPollTimer);
|
||
__currentJobPollTimer = null;
|
||
}
|
||
}, 5000);
|
||
}
|
||
// Local tick: refresh the elapsed text once per second so the
|
||
// counter never freezes between server polls. Pure DOM update,
|
||
// never triggers render().
|
||
if (!__inflightTickTimer) {
|
||
__inflightTickTimer = setInterval(() => {
|
||
if (!state.currentJob) {
|
||
clearInterval(__inflightTickTimer);
|
||
__inflightTickTimer = null;
|
||
return;
|
||
}
|
||
updateInflightElapsedDOM();
|
||
}, 1000);
|
||
}
|
||
}
|
||
|
||
// Returns true when the given provider id has enough configuration
|
||
// to actually be usable. Used by submit-disabled logic to gate the
|
||
// Summarize button so users don't click it with nothing wired up.
|
||
// - Relay: usable when the relay URL is reachable
|
||
// (state.relayStatus.configured, which reflects the
|
||
// operator-controlled hardcoded URL at build time). Credits
|
||
// availability is handled server-side — we don't gate the
|
||
// click on it, just surface errors after the call lands.
|
||
// - Other providers: usable when at least one of the required
|
||
// fields (apiKey / baseURL) has a value in either localStorage
|
||
// or the StartOS server config. Auto-detected Ollama (via the
|
||
// StartOS dependency) counts as configured.
|
||
// - Providers with no required fields (currently just relay)
|
||
// fall through to the relay branch above.
|
||
function providerCanRun(providerId) {
|
||
const provider = PROVIDER_BY_ID[providerId];
|
||
if (!provider) return false;
|
||
if (providerId === "relay") {
|
||
// The relay URL is hardcoded into this Recap build, so from
|
||
// the client's POV it's always "runnable" the moment the
|
||
// user picks it. We DON'T gate on state.relayStatus.configured
|
||
// here — that field depends on an async /api/relay/status
|
||
// round-trip, which races against the user typing a URL and
|
||
// clicking Summarize. If the relay turns out to be unreachable
|
||
// at submit time (StartTunnel down, etc.) the SSE response
|
||
// surfaces the error inline — much clearer than a silently
|
||
// disabled button.
|
||
return true;
|
||
}
|
||
const opts = state.providerOpts[providerId] || {};
|
||
const serverFields = state.providerServerStatus?.[providerId] || {};
|
||
if (provider.keyField) {
|
||
const local = (opts[provider.keyField.key] || "").trim();
|
||
if (local) return true;
|
||
if (serverFields[provider.keyField.key]) return true;
|
||
}
|
||
if (provider.urlField) {
|
||
const local = (opts[provider.urlField.key] || "").trim();
|
||
if (local) return true;
|
||
if (serverFields[provider.urlField.key]) return true;
|
||
if (providerId === "ollama" && discoveredUrlFor("ollama")) return true;
|
||
}
|
||
if (!provider.keyField && !provider.urlField) return true;
|
||
return false;
|
||
}
|
||
|
||
// Both pipelines (transcription + analysis) must have usable
|
||
// configuration for the request to succeed. Per-pipeline check
|
||
// because a user can mix providers (e.g. Whisper transcribe +
|
||
// Relay analyze).
|
||
function providersCanRun() {
|
||
return (
|
||
providerCanRun(state.transcriptionProvider) &&
|
||
providerCanRun(state.analysisProvider)
|
||
);
|
||
}
|
||
|
||
// True when the user has at least one AI provider credential set
|
||
// (localStorage OR server-side). Used by the toolbar status pill
|
||
// to surface "BYO AI keys configured" when relay credits are
|
||
// exhausted or unavailable but the user can still summarize via
|
||
// their own keys. Excludes the relay provider itself (its identity
|
||
// is server-managed, not a BYO credential).
|
||
function hasAnyBYOConfigured() {
|
||
for (const id of Object.keys(state.providerOpts || {})) {
|
||
if (id === "relay") continue;
|
||
const opts = state.providerOpts[id] || {};
|
||
for (const k of Object.keys(opts)) {
|
||
if (typeof opts[k] === "string" && opts[k].trim()) return true;
|
||
}
|
||
}
|
||
for (const id of Object.keys(state.providerServerStatus || {})) {
|
||
if (id === "relay") continue;
|
||
const fields = state.providerServerStatus[id] || {};
|
||
for (const k of Object.keys(fields)) {
|
||
if (fields[k]) return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// Compact toolbar status slot — sits between the URL input and
|
||
// the right-side icon buttons in the top bar. Shows whichever is
|
||
// most useful right now: relay credit balance, "BYO configured",
|
||
// or nothing if neither applies. Pairs Upgrade + "I have a key"
|
||
// buttons inline so the user has a one-click path to either tier.
|
||
function renderToolbarStatus() {
|
||
const free = !isLicensed();
|
||
const rs = state.relayStatus || {};
|
||
const credits = rs.creditsRemaining;
|
||
const byo = hasAnyBYOConfigured();
|
||
const showUpgrade = !isProTier();
|
||
const showIHaveKey = free;
|
||
const buyUrl = upgradeToProUrl();
|
||
|
||
// Pick the pill. Relay-credit count beats BYO badge when relay
|
||
// is configured — that's the actionable number the user cares
|
||
// about. Falls back to BYO badge when relay isn't configured
|
||
// (or returned an error) but the user has their own keys.
|
||
let pillHtml = "";
|
||
if (rs.configured && credits != null) {
|
||
if (credits < 0) {
|
||
pillHtml = `<span class="status-pill" style="color:#86efac;background:rgba(134,239,172,0.10);border-color:rgba(134,239,172,0.25);">Unlimited relay</span>`;
|
||
} else {
|
||
const color = credits === 0 ? "#fca5a5" : (credits <= 3 ? "#fbbf24" : "#a5b4fc");
|
||
const bg = credits === 0 ? "rgba(252,165,165,0.10)" : (credits <= 3 ? "rgba(251,191,36,0.10)" : "rgba(99,102,241,0.10)");
|
||
const border = credits === 0 ? "rgba(252,165,165,0.30)" : (credits <= 3 ? "rgba(251,191,36,0.30)" : "rgba(99,102,241,0.30)");
|
||
pillHtml = `<span class="status-pill" title="Relay credits remaining for this install" style="color:${color};background:${bg};border-color:${border};">${credits} relay credit${credits === 1 ? "" : "s"}</span>`;
|
||
}
|
||
} else if (byo) {
|
||
pillHtml = `<span class="status-pill" title="At least one AI provider key is configured" style="color:#86efac;background:rgba(134,239,172,0.10);border-color:rgba(134,239,172,0.25);">BYO AI keys configured</span>`;
|
||
} else if (rs.configured && rs.lastError) {
|
||
// Relay configured but unreachable AND no BYO fallback — make
|
||
// sure the user sees something so they know summarize will fail.
|
||
pillHtml = `<span class="status-pill" title="${escHtml(rs.lastError)}" style="color:#fca5a5;background:rgba(252,165,165,0.10);border-color:rgba(252,165,165,0.30);">Relay unreachable</span>`;
|
||
}
|
||
|
||
if (!pillHtml && !showUpgrade && !showIHaveKey) return "";
|
||
return `
|
||
<div class="top-bar-status">
|
||
${pillHtml}
|
||
${showUpgrade ? `<a href="${escHtml(buyUrl)}" target="_blank" rel="noopener" class="upgrade-btn">Upgrade</a>` : ""}
|
||
${showIHaveKey ? `<button onclick="showActivationScreen()" class="have-key-btn">I have a key</button>` : ""}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderUpgradeBanner() {
|
||
const buyUrl = upgradeToProUrl();
|
||
const free = !isLicensed();
|
||
// After the library-for-everyone change, the only meaningful tier
|
||
// distinction surfaced in the banner is "Free → upgrade for
|
||
// auto-queue + relay credits". Partial-license states no longer
|
||
// exist (library + history are universally available).
|
||
let label, descr;
|
||
if (free) {
|
||
label = "Free mode";
|
||
descr = "one video at a time · bring your own API key · upgrade for auto-queue, clips, and relay credits";
|
||
} else if (!isProTier()) {
|
||
label = "Paid license";
|
||
descr = "your license is missing some paid features — contact the seller";
|
||
} else {
|
||
return ""; // Pro tier (or above) — no banner
|
||
}
|
||
|
||
return `
|
||
<div class="upgrade-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;">${label}</strong>
|
||
· ${descr}
|
||
</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>
|
||
${free ? `
|
||
<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("recap-activation-skipped"); } catch {}
|
||
render();
|
||
}
|
||
|
||
function render() {
|
||
// Admin login gate (set via the StartOS "Set Admin Password"
|
||
// action) takes priority over everything: nobody — licensed or
|
||
// not — sees the activation screen or the app until they've
|
||
// signed in.
|
||
if (state.admin.loaded && state.admin.enabled && !state.admin.authed) {
|
||
const app = document.getElementById("app");
|
||
app.className = "container";
|
||
app.innerHTML = renderAdminLoginScreen();
|
||
const pwd = document.getElementById("admin-login-password");
|
||
const usr = app.querySelector("input[autocomplete='username']");
|
||
if (state.adminLoginUsername && pwd) {
|
||
pwd.focus();
|
||
} else if (usr) {
|
||
usr.focus();
|
||
}
|
||
return;
|
||
}
|
||
// Initial paint while license-status is still in-flight: show the
|
||
// activation card in its loading skeleton state rather than a flash of
|
||
// the underlying app.
|
||
if (!state.license.loaded) {
|
||
const app = document.getElementById("app");
|
||
app.className = "container";
|
||
app.innerHTML = renderActivationScreen();
|
||
return;
|
||
}
|
||
// 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();
|
||
// Submit is disabled when there's no URL, or when the selected
|
||
// providers don't have any usable configuration. Relay counts as
|
||
// configured whenever the relay URL is reachable (operator-set
|
||
// at build time); per-provider keys are accepted from either
|
||
// localStorage (browser-side picker entry) or the server-side
|
||
// StartOS config. This logic replaces the legacy "must have a
|
||
// Gemini key" check which prevented Summarize when Relay was
|
||
// selected with no Gemini key entered.
|
||
const submitDisabled = !state.url.trim()
|
||
|| (!isSubscribeUrl(state.url) && !providersCanRun());
|
||
let __renderedHtml;
|
||
try {
|
||
__renderedHtml = `
|
||
<!-- Top bar: title + action icons -->
|
||
<div class="top-bar">
|
||
<div class="top-left-actions">
|
||
<button class="icon-btn ${state.historyOpen ? "active" : ""}" onclick="handleLibraryClick()"
|
||
title="Library">
|
||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||
<circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
<div class="top-bar-input">
|
||
<input type="text" class="url-input"
|
||
placeholder="${state.loading ? (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>
|
||
${renderToolbarStatus()}
|
||
<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() : ""}
|
||
|
||
${renderCurrentJobBanner()}
|
||
<!-- renderUpgradeBanner moved inline into the top toolbar via
|
||
renderToolbarStatus(). Old banner kept as a function for
|
||
possible future use but no longer rendered. -->
|
||
|
||
|
||
${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.loading && state.error && state.videoId && state.chunks.length === 0 && state.currentType !== "podcast" ? renderErroredVideoPlaceholder() : ""}
|
||
|
||
${state.logOpen ? renderLogDrawer() : ""}
|
||
|
||
${state.historyOpen ? renderHistorySidebar() : ""}
|
||
${state.clipPanelOpen ? renderClipPanel() : ""}
|
||
`;
|
||
app.innerHTML = __renderedHtml;
|
||
} catch (renderErr) {
|
||
// A thrown exception inside any ${...} expression silently aborts
|
||
// the innerHTML assignment, leaving the previous DOM in place — so
|
||
// a button click that calls render() looks like a no-op. Surface
|
||
// the actual error instead of leaving the user wondering.
|
||
console.error("[render] failed to build HTML:", renderErr);
|
||
app.innerHTML = `<div class="error-box" style="margin:20px;">
|
||
<strong>Render error:</strong> ${escHtml(renderErr && renderErr.message || String(renderErr))}
|
||
<div style="font-size:11px;margin-top:6px;opacity:0.7;">
|
||
Check the browser console for the full stack trace, or share it with the developer.
|
||
</div>
|
||
</div>`;
|
||
return;
|
||
}
|
||
// 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>
|
||
`;
|
||
}
|
||
|
||
// ── AI Providers settings block ──────────────────────────────────────────
|
||
// Small inline summary near the picker: tier name + remaining
|
||
// credits. Hidden when the operator hasn't set RELAY_BASE_URL yet
|
||
// (configured=false) since there's nothing to display. Shows a
|
||
// gentle warning when lastError is populated so the user knows
|
||
// the count is stale.
|
||
function renderRelayStatusPill() {
|
||
const rs = state.relayStatus || {};
|
||
if (!rs.configured) return ""; // relay not provisioned — hide.
|
||
const tier = rs.tier || "core";
|
||
const credits = rs.creditsRemaining;
|
||
const tierLabel = tier === "max" ? "Max" : tier === "pro" ? "Pro" : "Core";
|
||
const creditLabel =
|
||
credits == null
|
||
? "balance unknown — no relay calls yet"
|
||
: credits === Infinity || credits < 0
|
||
? "unlimited"
|
||
: `${credits} credit${credits === 1 ? "" : "s"} remaining`;
|
||
const errorBadge = rs.lastError
|
||
? `<span style="color:#f87171;font-size:10px;">· ${escHtml(rs.lastError.slice(0, 80))}</span>`
|
||
: "";
|
||
return `
|
||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0 8px;padding:6px 10px;background:rgba(99,102,241,0.10);border:1px solid rgba(99,102,241,0.30);border-radius:8px;font-size:11px;color:#cbd5e1;">
|
||
<span style="font-weight:600;color:#a5b4fc;">Relay</span>
|
||
<span>· Tier: <strong style="color:#e2e8f0;">${tierLabel}</strong></span>
|
||
<span>· ${creditLabel}</span>
|
||
${errorBadge}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderProvidersBlock() {
|
||
const tp = PROVIDER_BY_ID[state.transcriptionProvider] || PROVIDERS[0];
|
||
const ap = PROVIDER_BY_ID[state.analysisProvider] || PROVIDERS[0];
|
||
// Show the "Use comped credits" reset link when the user has
|
||
// strayed from the relay defaults. One-click way back to the
|
||
// out-of-the-box state without having to manually click both
|
||
// dropdowns.
|
||
const onRelayDefaults =
|
||
state.transcriptionProvider === "relay" &&
|
||
state.analysisProvider === "relay";
|
||
const resetLink = onRelayDefaults
|
||
? ""
|
||
: `<button onclick="resetProvidersToRelay()" title="Reset both pickers to use the operator's relay (comped credits) — your saved keys for other providers are kept, just unselected."
|
||
style="background:transparent;color:#a5b4fc;border:1px solid rgba(99,102,241,0.35);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:10px;font-weight:600;margin-top:4px;align-self:flex-start;"
|
||
onmouseover="this.style.background='rgba(99,102,241,0.10)'"
|
||
onmouseout="this.style.background='transparent'">
|
||
↺ Use comped credits (reset to relay)
|
||
</button>`;
|
||
return `
|
||
<label class="field-label" style="margin-top:14px;">AI Providers</label>
|
||
${renderRelayStatusPill()}
|
||
<div style="display:flex;flex-direction:column;gap:10px;border:1px solid #334155;background:rgba(30,41,59,0.3);border-radius:8px;padding:12px;">
|
||
${resetLink}
|
||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||
<span style="font-size:11px;color:#94a3b8;font-weight:600;">Transcription</span>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
${renderProviderSelect("transcription", TRANSCRIBE_PROVIDERS, state.transcriptionProvider)}
|
||
<span data-model-slot="transcription" style="display:flex;flex:1 1 160px;min-width:140px;">${renderModelInput("transcription", tp, state.transcriptionModel)}</span>
|
||
</div>
|
||
<span style="font-size:10px;color:#64748b;">${escHtml(tp.canTranscribe ? "Audio → text" : "(provider does not support audio — pick gemini or openai)")}</span>
|
||
</div>
|
||
<div style="display:flex;flex-direction:column;gap:4px;">
|
||
<span style="font-size:11px;color:#94a3b8;font-weight:600;">Analysis</span>
|
||
<div style="display:flex;gap:6px;flex-wrap:wrap;">
|
||
${renderProviderSelect("analysis", ANALYZE_PROVIDERS, state.analysisProvider)}
|
||
<span data-model-slot="analysis" style="display:flex;flex:1 1 160px;min-width:140px;">${renderModelInput("analysis", ap, state.analysisModel)}</span>
|
||
</div>
|
||
<span style="font-size:10px;color:#64748b;">Topic structuring (text → JSON) · falls back through remaining models if your chosen one fails</span>
|
||
</div>
|
||
<label style="display:flex;align-items:center;gap:8px;font-size:11px;color:#cbd5e1;cursor:pointer;padding-top:6px;border-top:1px solid #1e293b;">
|
||
<input type="checkbox" ${state.useYouTubeCaptions ? "checked" : ""} onchange="setUseYouTubeCaptions(this.checked)" style="margin:0;" />
|
||
<span>
|
||
Use YouTube captions when available
|
||
<span style="color:#64748b;display:block;font-size:10px;margin-top:1px;">Skips audio download + transcription. Faster, but captions don't have speaker labels — uncheck if you want them.</span>
|
||
</span>
|
||
</label>
|
||
</div>
|
||
|
||
<label class="field-label" style="margin-top:14px;display:flex;align-items:center;justify-content:space-between;">
|
||
<span>API Keys & Endpoints</span>
|
||
<button onclick="toggleShowKey()" style="background:none;border:none;color:#94a3b8;cursor:pointer;font-size:11px;">${state.showKey ? "Hide values" : "Show values"}</button>
|
||
</label>
|
||
<div style="display:flex;flex-direction:column;gap:10px;border:1px solid #334155;background:rgba(30,41,59,0.3);border-radius:8px;padding:12px;">
|
||
${PROVIDERS.map(renderProviderCredentials).join("")}
|
||
<p style="font-size:10px;color:#64748b;margin:0;line-height:1.5;">
|
||
Keys typed here are saved locally in this browser. Keys set via the <strong>Set ${escHtml('<Provider>')} API Key</strong> StartOS actions are saved on the server (shared across devices); look for the green “✓ Server-configured” hint under a field. <strong>Delete</strong> clears both at once.
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderProviderSelect(pipeline, providers, selectedId) {
|
||
// pipeline = "transcription" | "analysis" — drives the change handler.
|
||
const options = providers.map((p) =>
|
||
`<option value="${escHtml(p.id)}" ${p.id === selectedId ? "selected" : ""}>${escHtml(p.name)}</option>`
|
||
).join("");
|
||
return `<select onchange="setProvider('${pipeline}', this.value)" class="key-input" style="flex:1 1 200px;min-width:160px;">${options}</select>`;
|
||
}
|
||
|
||
function renderModelInput(pipeline, provider, currentModel) {
|
||
const onchange = pipeline === "transcription"
|
||
? "setTranscriptionModel(this.value)"
|
||
: "setAnalysisModel(this.value)";
|
||
const list = pipeline === "transcription"
|
||
? resolvedTranscriptionModelsFor(provider)
|
||
: resolvedAnalysisModelsFor(provider);
|
||
if (!list || list.length === 0) {
|
||
// No list anywhere → free-text input. Happens for openai-
|
||
// compatible / ollama before the user defines their models in
|
||
// credentials and before we've fetched any from the server.
|
||
const placeholder = pipeline === "transcription"
|
||
? (provider.canTranscribe ? "model name" : "—")
|
||
: (provider.analysisModelDefault || "model name");
|
||
return `<input type="text" placeholder="${escHtml(placeholder)}"
|
||
value="${escHtml(currentModel || '')}"
|
||
oninput="${onchange}"
|
||
${!provider.canTranscribe && pipeline === "transcription" ? "disabled" : ""}
|
||
class="key-input" style="flex:1 1 160px;min-width:140px;" />`;
|
||
}
|
||
// If the saved model isn't in the resolved list, surface it as
|
||
// an extra entry so the dropdown can show what's currently
|
||
// selected (e.g. a model the user typed before defining their
|
||
// list, or a stale value from an older session).
|
||
const fullList = currentModel && !list.includes(currentModel)
|
||
? [currentModel, ...list]
|
||
: list;
|
||
const options = fullList.map((m) =>
|
||
`<option value="${escHtml(m)}" ${m === currentModel ? "selected" : ""}>${escHtml(m.replace("-preview", ""))}</option>`
|
||
).join("");
|
||
return `<select onchange="${onchange}" class="key-input" style="flex:1 1 160px;min-width:140px;">${options}</select>`;
|
||
}
|
||
|
||
// Default-expanded set: only the providers currently SELECTED for
|
||
// either pipeline (transcription or analysis). Everything else
|
||
// collapses by default — even providers that have saved
|
||
// credentials, because seeing all of them sprawled open made the
|
||
// settings panel hard to scan and obscured the active pair.
|
||
// Users can click the chevron to expand any provider on demand.
|
||
function isProviderExpandedByDefault(providerId) {
|
||
if (providerId === state.transcriptionProvider) return true;
|
||
if (providerId === state.analysisProvider) return true;
|
||
return false;
|
||
}
|
||
|
||
function isProviderExpanded(providerId) {
|
||
if (state.providerExpanded && providerId in state.providerExpanded) {
|
||
return !!state.providerExpanded[providerId];
|
||
}
|
||
return isProviderExpandedByDefault(providerId);
|
||
}
|
||
|
||
// Surgical toggle — no full render. Flips state.providerExpanded
|
||
// and mutates just the section's content + chevron icon DOM in
|
||
// place, so nothing else on the settings panel redraws.
|
||
function toggleProviderSection(providerId) {
|
||
const expanded = !isProviderExpanded(providerId);
|
||
if (!state.providerExpanded) state.providerExpanded = {};
|
||
state.providerExpanded[providerId] = expanded;
|
||
const section = document.querySelector(`[data-provider-section="${providerId}"]`);
|
||
if (!section) return;
|
||
const body = section.querySelector('[data-provider-body]');
|
||
const chevron = section.querySelector('[data-provider-chevron]');
|
||
if (body) body.style.display = expanded ? "" : "none";
|
||
if (chevron) chevron.textContent = expanded ? "▾" : "▸";
|
||
}
|
||
|
||
function renderProviderCredentials(provider) {
|
||
const opts = state.providerOpts[provider.id] || {};
|
||
const inputType = state.showKey ? "text" : "password";
|
||
const expanded = isProviderExpanded(provider.id);
|
||
let inner = `<div style="font-size:11px;color:#cbd5e1;font-weight:600;display:flex;align-items:center;justify-content:space-between;gap:6px;cursor:pointer;" onclick="toggleProviderSection('${provider.id}')">
|
||
<span style="display:flex;align-items:center;gap:6px;">
|
||
<span data-provider-chevron style="color:#64748b;font-size:10px;width:10px;display:inline-block;">${expanded ? "▾" : "▸"}</span>
|
||
${escHtml(provider.name)}
|
||
</span>
|
||
<div style="display:flex;gap:6px;align-items:center;" onclick="event.stopPropagation()">
|
||
${renderProviderTestControl(provider)}
|
||
<span data-save-slot="${provider.id}">${renderProviderSaveControl(provider)}</span>
|
||
</div>
|
||
</div>
|
||
<div data-provider-body style="display:${expanded ? "flex" : "none"};flex-direction:column;gap:5px;">`;
|
||
if (provider.urlField) {
|
||
// If the server auto-discovered a URL for this provider (e.g.
|
||
// Ollama installed alongside us on StartOS), use it as the
|
||
// placeholder + add a hint underneath. Empty saved value will
|
||
// still let the server fall back to the discovered URL.
|
||
const discovered = discoveredUrlFor(provider.id);
|
||
const ph = discovered || provider.urlField.placeholder;
|
||
const localUrl = opts[provider.urlField.key] || "";
|
||
const urlOnServer = providerFieldOnServer(provider.id, provider.urlField.key);
|
||
inner += `
|
||
<input type="text" placeholder="${escHtml(ph)}"
|
||
value="${escHtml(localUrl)}"
|
||
oninput="setProviderOpt('${provider.id}', '${provider.urlField.key}', this.value)"
|
||
class="key-input" style="width:100%;" />`;
|
||
if (discovered) {
|
||
inner += `<div style="font-size:10px;color:#86efac;">Auto-detected on this StartOS server — leave blank to use it</div>`;
|
||
} else if (!localUrl && urlOnServer) {
|
||
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action — leave blank to use it</div>`;
|
||
}
|
||
}
|
||
if (provider.keyField) {
|
||
const t = provider.keyField.masked ? inputType : "text";
|
||
const localValue = opts[provider.keyField.key] || "";
|
||
const onServer = providerFieldOnServer(provider.id, provider.keyField.key);
|
||
inner += `
|
||
<input type="${t}" placeholder="${escHtml(provider.keyField.placeholder)}"
|
||
value="${escHtml(localValue)}"
|
||
oninput="setProviderOpt('${provider.id}', '${provider.keyField.key}', this.value)"
|
||
class="key-input" style="width:100%;" />`;
|
||
if (!localValue && onServer) {
|
||
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action — leave blank to use it</div>`;
|
||
}
|
||
}
|
||
if (provider.modelsField) {
|
||
const discoveredModels = discoveredModelsFor(provider.id);
|
||
const ph = provider.modelsField.placeholder;
|
||
const hintParts = [provider.modelsField.hint];
|
||
if (discoveredModels.length > 0) {
|
||
hintParts.push(`Detected on your server: <code style="color:#86efac;font-size:10px;">${escHtml(discoveredModels.join(", "))}</code>`);
|
||
}
|
||
inner += `
|
||
<input type="text" placeholder="${escHtml(ph)}"
|
||
value="${escHtml(opts[provider.modelsField.key] || '')}"
|
||
oninput="setProviderOpt('${provider.id}', '${provider.modelsField.key}', this.value)"
|
||
class="key-input" style="width:100%;" />
|
||
<div style="font-size:10px;color:#64748b;">${hintParts.join(" · ")} · <em>click Save to refresh the model dropdown above</em></div>`;
|
||
}
|
||
if (!provider.urlField && !provider.keyField) {
|
||
inner += `<div style="font-size:10px;color:#64748b;">No configuration needed.</div>`;
|
||
}
|
||
// Inline test result lands here when the user hits Test.
|
||
const test = state.providerTestResults?.[provider.id];
|
||
if (test) {
|
||
const colour = test.ok ? "#86efac" : "#fca5a5";
|
||
const icon = test.ok ? "✓" : "✗";
|
||
const body = test.ok
|
||
? `${escHtml(test.text || "(empty response)")} <span style="color:#64748b;">· ${test.latencyMs}ms</span>`
|
||
: escHtml(test.error || "failed");
|
||
inner += `<div style="font-size:10px;color:${colour};margin-top:2px;">${icon} ${body}</div>`;
|
||
}
|
||
// Close the data-provider-body div opened in the header block.
|
||
inner += `</div>`;
|
||
return `<div data-provider-section="${provider.id}" style="display:flex;flex-direction:column;gap:5px;border-top:1px solid #1e293b;padding-top:8px;">${inner}</div>`;
|
||
}
|
||
|
||
// The small "Test" button + spinner shown next to each provider's
|
||
// name in the credentials section. Disabled when the provider has
|
||
// no analysis capability (i.e. nothing meaningful to test).
|
||
function renderProviderTestControl(provider) {
|
||
if (!provider.canAnalyze) return "";
|
||
const testing = state.providerTesting?.[provider.id];
|
||
if (testing) {
|
||
return `<span style="font-size:10px;color:#94a3b8;">Testing…</span>`;
|
||
}
|
||
return `<button onclick="testProvider('${provider.id}')"
|
||
style="background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:2px 8px;font-size:10px;cursor:pointer;">Test</button>`;
|
||
}
|
||
|
||
// Returns true when the provider has any user-configurable field
|
||
// (key, URL, models). Used to decide whether to render the
|
||
// Save/Delete buttons at all — providers like Relay have no
|
||
// user-editable fields (identity + URL are server-side), so the
|
||
// buttons would be no-ops.
|
||
function providerHasConfigurableFields(provider) {
|
||
return !!(provider.keyField || provider.urlField || provider.modelsField);
|
||
}
|
||
|
||
// Save button shown next to each provider's name. Click flips it
|
||
// to a green "✓ Saved" pill for ~2.5s, then back to "Save". This
|
||
// is the only place we re-render the providers block after the
|
||
// user types — keystrokes update state silently (via
|
||
// setProviderOpt, no render()) so typing doesn't flash the
|
||
// screen. Save triggers the one render needed to refresh the
|
||
// model picker dropdown above with any newly-typed model names.
|
||
function renderProviderSaveControl(provider) {
|
||
if (!providerHasConfigurableFields(provider)) return "";
|
||
const saved = state.providerSaveState?.[provider.id] === "saved";
|
||
if (saved) {
|
||
return `<span style="font-size:10px;color:#86efac;display:inline-flex;align-items:center;gap:3px;background:rgba(134,239,172,0.08);border:1px solid rgba(134,239,172,0.4);border-radius:4px;padding:2px 8px;">✓ Saved</span>`;
|
||
}
|
||
const hasAnyValue = providerHasAnyStoredValue(provider);
|
||
const deleteBtn = hasAnyValue
|
||
? `<button onclick="deleteProviderSection('${provider.id}')" title="Clear this provider's credentials from both this browser AND the server"
|
||
style="background:transparent;color:#94a3b8;border:1px solid #334155;border-radius:4px;padding:2px 10px;font-size:10px;font-weight:600;cursor:pointer;"
|
||
onmouseover="this.style.borderColor='#dc2626';this.style.color='#f87171'"
|
||
onmouseout="this.style.borderColor='#334155';this.style.color='#94a3b8'">Delete</button>`
|
||
: "";
|
||
return `${deleteBtn}<button onclick="saveProviderSection('${provider.id}')"
|
||
style="background:#1e293b;color:#cbd5e1;border:1px solid #475569;border-radius:4px;padding:2px 10px;font-size:10px;font-weight:600;cursor:pointer;margin-left:6px;">Save</button>`;
|
||
}
|
||
|
||
// Returns true if this provider has a stored value in EITHER the
|
||
// localStorage opts OR the server-side StartOS config. Drives
|
||
// whether the Delete button is visible — clicking it clears both,
|
||
// so we want to show it as long as anything is set on either side.
|
||
function providerHasAnyStoredValue(provider) {
|
||
const opts = state.providerOpts[provider.id] || {};
|
||
for (const k of Object.keys(opts)) {
|
||
if (typeof opts[k] === "string" && opts[k].trim() !== "") return true;
|
||
}
|
||
const serverFields = state.providerServerStatus?.[provider.id] || {};
|
||
for (const k of Object.keys(serverFields)) {
|
||
if (serverFields[k]) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// True when the server has a non-empty value for this specific
|
||
// (providerId, fieldName) pair. Used to render the inline
|
||
// "✓ Server-configured" hint under an empty input field so the
|
||
// user can tell the provider is already wired up via the StartOS
|
||
// action even though the local input is blank.
|
||
function providerFieldOnServer(providerId, fieldName) {
|
||
const fields = state.providerServerStatus?.[providerId] || {};
|
||
return !!fields[fieldName];
|
||
}
|
||
|
||
// Delete a provider's credentials from BOTH localStorage and the
|
||
// StartOS config. Confirms first — this can't be undone (the user
|
||
// has to re-enter via the picker or re-run the StartOS action).
|
||
async function deleteProviderSection(providerId) {
|
||
const provider = PROVIDER_BY_ID[providerId];
|
||
if (!provider) return;
|
||
const proceed = confirm(
|
||
`Delete ${provider.name} credentials?\n\n` +
|
||
"This clears them from BOTH this browser AND the server. " +
|
||
"To use this provider again you'll need to re-enter them in Settings or via the StartOS \"Set " +
|
||
provider.name +
|
||
" API Key\" action."
|
||
);
|
||
if (!proceed) return;
|
||
// Local wipe
|
||
state.providerOpts[providerId] = {};
|
||
saveProviderOpts();
|
||
// Server wipe (best-effort — local is already gone if this fails)
|
||
try {
|
||
await fetch(`${API_BASE}/api/providers/${providerId}/clear`, {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
});
|
||
} catch {}
|
||
// Pull any newly-empty server-discovered URL/models for fresh
|
||
// placeholder rendering, refresh the per-field server-config
|
||
// status so the Delete button + "✓ Server-configured" hints
|
||
// reflect the cleared state, then re-render.
|
||
await Promise.all([
|
||
loadProviderDiscovery().catch(() => {}),
|
||
loadProviderServerStatus().catch(() => {}),
|
||
]);
|
||
render();
|
||
}
|
||
|
||
// Confirms a provider's credentials by re-persisting (already
|
||
// happened on every keystroke, but defensive), flashing a green
|
||
// ✓ Saved pill for 2.5s, and triggering the one render() that
|
||
// refreshes the model-picker dropdown above with any user-defined
|
||
// models. This is the visible "save" the user sees — auto-save
|
||
// to localStorage happens silently in the background to prevent
|
||
// data loss on a stray browser refresh.
|
||
function saveProviderSection(providerId) {
|
||
saveProviderOpts();
|
||
if (!state.providerSaveState) state.providerSaveState = {};
|
||
state.providerSaveState[providerId] = "saved";
|
||
// Surgical update: swap the save-button slot for this provider
|
||
// into the green "✓ Saved" pill, and refresh the model-picker
|
||
// dropdowns at the top so any newly-typed models appear. No
|
||
// full render — typing/scroll state on the rest of the page
|
||
// stays intact (this is what the user complained about).
|
||
const slot = document.querySelector(`[data-save-slot="${providerId}"]`);
|
||
if (slot) slot.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]);
|
||
refreshModelPickersSurgical();
|
||
setTimeout(() => {
|
||
if (state.providerSaveState) delete state.providerSaveState[providerId];
|
||
const slotNow = document.querySelector(`[data-save-slot="${providerId}"]`);
|
||
if (slotNow) slotNow.innerHTML = renderProviderSaveControl(PROVIDER_BY_ID[providerId]);
|
||
}, 2500);
|
||
}
|
||
|
||
// Replace the transcription + analysis model dropdown options
|
||
// in place when the user's Models field changes. Doesn't touch
|
||
// anything else in the settings panel.
|
||
function refreshModelPickersSurgical() {
|
||
const tp = PROVIDER_BY_ID[state.transcriptionProvider] || PROVIDERS[0];
|
||
const ap = PROVIDER_BY_ID[state.analysisProvider] || PROVIDERS[0];
|
||
const tSlot = document.querySelector('[data-model-slot="transcription"]');
|
||
const aSlot = document.querySelector('[data-model-slot="analysis"]');
|
||
if (tSlot) tSlot.innerHTML = renderModelInput("transcription", tp, state.transcriptionModel);
|
||
if (aSlot) aSlot.innerHTML = renderModelInput("analysis", ap, state.analysisModel);
|
||
}
|
||
|
||
// Pings the provider with a tiny 3-word prompt. Uses whichever model
|
||
// is currently selected in the Analysis picker for that provider —
|
||
// or, if a different provider is selected analysis-side, the first
|
||
// entry from the resolved model list.
|
||
async function testProvider(providerId) {
|
||
const provider = PROVIDER_BY_ID[providerId];
|
||
if (!provider) return;
|
||
// Auto-expand this provider's section so the inline test result
|
||
// (which renders inside the body div) is visible. Without this,
|
||
// clicking Test on a collapsed section caused a "screen flash
|
||
// with no apparent answer" — the result was rendering inside
|
||
// the hidden body.
|
||
if (!state.providerExpanded) state.providerExpanded = {};
|
||
state.providerExpanded[providerId] = true;
|
||
let model = "";
|
||
if (state.analysisProvider === providerId) {
|
||
model = state.analysisModel;
|
||
}
|
||
if (!model) {
|
||
const list = resolvedAnalysisModelsFor(provider);
|
||
model = list[0] || provider.analysisModelDefault || "";
|
||
}
|
||
if (!model) {
|
||
state.providerTestResults = state.providerTestResults || {};
|
||
state.providerTestResults[providerId] = {
|
||
ok: false,
|
||
error: "No model selected. Pick or type one above.",
|
||
};
|
||
render();
|
||
return;
|
||
}
|
||
state.providerTesting = state.providerTesting || {};
|
||
state.providerTesting[providerId] = true;
|
||
state.providerTestResults = state.providerTestResults || {};
|
||
delete state.providerTestResults[providerId];
|
||
render();
|
||
try {
|
||
const res = await fetch(`${API_BASE}/api/providers/test`, {
|
||
method: "POST",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
providerId,
|
||
model,
|
||
opts: state.providerOpts[providerId] || {},
|
||
}),
|
||
});
|
||
const data = await res.json().catch(() => ({ ok: false, error: `HTTP ${res.status}` }));
|
||
state.providerTestResults[providerId] = data;
|
||
} catch (e) {
|
||
state.providerTestResults[providerId] = { ok: false, error: e.message };
|
||
} finally {
|
||
state.providerTesting[providerId] = false;
|
||
render();
|
||
}
|
||
}
|
||
|
||
function setProvider(pipeline, providerId) {
|
||
const provider = PROVIDER_BY_ID[providerId];
|
||
if (!provider) return;
|
||
if (pipeline === "transcription") {
|
||
state.transcriptionProvider = providerId;
|
||
// Snap the model to a sensible default for this provider:
|
||
// catalog → user-defined Models field → server-discovered.
|
||
// This is what makes "switch to Whisper, see my Parakeet
|
||
// model name pre-filled" actually work.
|
||
const list = resolvedTranscriptionModelsFor(provider);
|
||
state.transcriptionModel = list[0] || "";
|
||
} else {
|
||
state.analysisProvider = providerId;
|
||
const list = resolvedAnalysisModelsFor(provider);
|
||
state.analysisModel = list[0] || provider.analysisModelDefault || "";
|
||
}
|
||
saveProviderSelection();
|
||
render();
|
||
}
|
||
|
||
function setTranscriptionModel(model) {
|
||
state.transcriptionModel = (model || "").trim();
|
||
saveProviderSelection();
|
||
}
|
||
|
||
function setAnalysisModel(model) {
|
||
state.analysisModel = (model || "").trim();
|
||
saveProviderSelection();
|
||
}
|
||
|
||
function setProviderOpt(providerId, field, value) {
|
||
if (!state.providerOpts[providerId]) state.providerOpts[providerId] = {};
|
||
state.providerOpts[providerId][field] = (value || "").trim();
|
||
saveProviderOpts();
|
||
}
|
||
|
||
function setUseYouTubeCaptions(checked) {
|
||
// No render() — the checkbox's visual state is already correct
|
||
// (user just clicked it), and state.useYouTubeCaptions is only
|
||
// read when submitting a URL. A full re-render here flashed the
|
||
// entire settings screen for no UI benefit.
|
||
state.useYouTubeCaptions = !!checked;
|
||
try { localStorage.setItem("recap-use-yt-captions", checked ? "1" : "0"); } catch {}
|
||
}
|
||
|
||
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()}
|
||
|
||
${renderProvidersBlock()}
|
||
|
||
${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.")}`}
|
||
|
||
${renderLibraryTransfer()}
|
||
|
||
${state.admin.enabled && state.admin.authed ? `
|
||
<label class="field-label" style="margin-top:12px;">Admin Session</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;">
|
||
Signed in as <strong>${escHtml(state.admin.username || "admin")}</strong>. The password is set on the server via the <strong>Set Admin Password</strong> StartOS action.
|
||
</span>
|
||
<button onclick="submitAdminLogout()" style="padding:6px 14px;font-size:12px;font-weight:600;background:#1e293b;color:#94a3b8;border:1px solid #334155;border-radius:6px;cursor:pointer;">Sign out</button>
|
||
</div>
|
||
` : ""}
|
||
|
||
</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 = "recap-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);
|
||
|
||
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";
|
||
// Match the full-render submit-disabled rule exactly so the
|
||
// button doesn't flicker between "enabled (full render)" and
|
||
// "disabled (surgical update)". providersCanRun() returns
|
||
// true when both the selected transcription and analysis
|
||
// providers have usable config (relay-configured, an API
|
||
// key in localStorage, server-side config, or auto-detected
|
||
// Ollama).
|
||
btn.disabled = !state.url.trim() || !providersCanRun();
|
||
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; }
|
||
}
|
||
|
||
// When processing errors out mid-stream, state.loading flips to
|
||
// false and the loading-split view (which had the YouTube embed +
|
||
// progress bar) unmounts — leaving just the error box and a blank
|
||
// panel. This branch puts the embed back so the user can still
|
||
// click through to watch on YouTube, and offers a one-click retry.
|
||
function renderErroredVideoPlaceholder() {
|
||
return `
|
||
<div class="results-split">
|
||
<div class="results-left">
|
||
<div class="video-embed">
|
||
<div id="yt-player"></div>
|
||
</div>
|
||
${renderWatchOnYouTubeLink()}
|
||
${state.videoTitle ? `<div class="video-title">${escHtml(state.videoTitle)}</div>` : ""}
|
||
</div>
|
||
<div class="results-right">
|
||
<div style="padding:18px;display:flex;flex-direction:column;gap:10px;align-items:flex-start;">
|
||
<div style="font-size:13px;color:#fca5a5;font-weight:600;">Processing failed before summary was ready.</div>
|
||
<div style="font-size:12px;color:#94a3b8;line-height:1.5;">
|
||
You can still watch the video using the link below the player, or try again — sometimes a different model in the Analysis dropdown gets through when one is overloaded.
|
||
</div>
|
||
<button onclick="processUrl(${JSON.stringify(state.url || "https://www.youtube.com/watch?v=" + state.videoId)})"
|
||
style="background:#6366f1;color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;">
|
||
Try again
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
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>
|
||
${renderWatchOnYouTubeLink()}
|
||
</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>
|
||
${renderWatchOnYouTubeLink()}
|
||
` : ""}
|
||
</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>
|
||
`;
|
||
}
|
||
|
||
// Always-visible "Watch on YouTube" link — channels can disable
|
||
// third-party embedding, and when they do, YouTube's iframe just
|
||
// shows a "Video unavailable" error. This link gives users a
|
||
// one-click fallback regardless. Only renders when we actually
|
||
// have a YouTube videoId (not for podcasts).
|
||
function renderWatchOnYouTubeLink() {
|
||
if (!state.videoId || state.currentType === "podcast") return "";
|
||
const url = `https://www.youtube.com/watch?v=${encodeURIComponent(state.videoId)}`;
|
||
return `
|
||
<a href="${url}" target="_blank" rel="noopener"
|
||
style="display:inline-flex;align-items:center;gap:4px;font-size:11px;color:#94a3b8;text-decoration:none;margin-top:6px;"
|
||
title="Open in YouTube (some channels disable embedding)">
|
||
Watch on YouTube
|
||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.7">
|
||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||
<polyline points="15 3 21 3 21 9"></polyline>
|
||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||
</svg>
|
||
</a>
|
||
`;
|
||
}
|
||
|
||
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>`;
|
||
}
|
||
|
||
// Read-only display of the per-install UUID minted by the server.
|
||
// The install-id is intentionally NOT surfaced in the UI — showing
|
||
// it would advertise the "uninstall + reinstall to reset credits"
|
||
// workaround. It's still generated server-side on first boot and
|
||
// sent to the relay as X-Recap-Install-Id for credit accounting,
|
||
// just not displayed anywhere user-visible.
|
||
|
||
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>
|
||
<div style="display:flex; align-items:center; gap:8px;">
|
||
${state.logs.length > 0 ? `
|
||
<button onclick="clearLogHistory()" title="Clear all activity-log entries"
|
||
style="background:transparent; border:1px solid #334155; color:#94a3b8; padding:4px 10px; border-radius:6px; cursor:pointer; font-size:11px; font-weight:600;"
|
||
onmouseover="this.style.borderColor='#dc2626'; this.style.color='#f87171';"
|
||
onmouseout="this.style.borderColor='#334155'; this.style.color='#94a3b8';">
|
||
Clear
|
||
</button>` : ""}
|
||
<button class="close-btn" onclick="toggleLog()">×</button>
|
||
</div>
|
||
</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>`
|
||
: renderLogEntries(state.logs)}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Render the body of the activity log. Each separator (── title ──)
|
||
// anchors a collapsible group: entries after the separator are
|
||
// hidden until the next separator when the group is collapsed.
|
||
// Group identity is the separator's index in state.logs — stable
|
||
// across renders, simple to toggle.
|
||
function renderLogEntries(logs) {
|
||
let out = "";
|
||
let activeGroupIdx = -1; // index of current separator; -1 means "before any separator"
|
||
let activeCollapsed = false;
|
||
let activeCount = 0; // entries seen in the current group (for the chip)
|
||
for (let i = 0; i < logs.length; i++) {
|
||
const l = logs[i];
|
||
if (l.separator) {
|
||
// Close the previous group's wrapper if we had one
|
||
if (activeGroupIdx >= 0) out += `</div>`;
|
||
activeGroupIdx = i;
|
||
activeCollapsed = state.collapsedLogGroups.has(i);
|
||
activeCount = 0;
|
||
const chevron = activeCollapsed ? "▸" : "▾";
|
||
out += `
|
||
<div class="log-entry log-group-header" onclick="toggleLogGroup(${i})"
|
||
style="cursor:pointer; user-select:none; border-top:1px solid #1e293b; margin-top:8px; padding-top:8px; color:#818cf8; font-weight:600; font-size:11px; display:flex; align-items:center; gap:6px;">
|
||
<span style="display:inline-block; width:10px; transform:translateY(-1px);">${chevron}</span>
|
||
<span class="log-msg" style="flex:1;">${escHtml(l.message)}</span>
|
||
<span class="log-group-count" data-group="${i}" style="font-size:10px; color:#64748b; font-weight:400;"></span>
|
||
</div>
|
||
<div class="log-group-body" data-group="${i}" style="${activeCollapsed ? "display:none;" : ""}">`;
|
||
continue;
|
||
}
|
||
// Non-separator entry. If we're not inside any group yet (logs
|
||
// without a leading separator), open an implicit group wrapper
|
||
// so future collapse logic doesn't break.
|
||
if (activeGroupIdx === -1) {
|
||
out += `<div class="log-group-body" data-group="-1">`;
|
||
activeGroupIdx = -1; // sentinel, won't be matched by toggle
|
||
activeCollapsed = false;
|
||
}
|
||
activeCount++;
|
||
out += `
|
||
<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>`;
|
||
}
|
||
if (activeGroupIdx !== -1 || logs.some((l) => !l.separator)) {
|
||
// Close the final group wrapper (either real or implicit)
|
||
out += `</div>`;
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function toggleLogGroup(idx) {
|
||
if (state.collapsedLogGroups.has(idx)) {
|
||
state.collapsedLogGroups.delete(idx);
|
||
} else {
|
||
state.collapsedLogGroups.add(idx);
|
||
}
|
||
render();
|
||
}
|
||
|
||
function renderLog() {
|
||
const el = document.getElementById("log-body");
|
||
if (!el || !state.logOpen) return;
|
||
const last = state.logs[state.logs.length - 1];
|
||
if (!last) return;
|
||
// Separators introduce a brand-new collapsible group + body
|
||
// wrapper; the structural shape is non-trivial to splice in
|
||
// surgically, so just re-render the drawer body for separators
|
||
// and keep the fast append path for the common case (plain log
|
||
// entries).
|
||
if (last.separator) {
|
||
el.innerHTML = renderLogEntries(state.logs);
|
||
el.scrollTop = el.scrollHeight;
|
||
return;
|
||
}
|
||
const entry = document.createElement("div");
|
||
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>`;
|
||
// Append into the most recent group's body if one exists; else
|
||
// append at the drawer root (logs without a leading separator).
|
||
const groups = el.querySelectorAll(".log-group-body");
|
||
const target = groups.length ? groups[groups.length - 1] : el;
|
||
target.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;
|
||
|
||
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">
|
||
<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>
|
||
<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(); }
|
||
// Legacy: pre-picker-UI code paths called setApiKey() to update the
|
||
// single Gemini key. New flow goes through setProviderOpt('gemini',
|
||
// 'apiKey', v). Route this through the same persistence so the two
|
||
// storage slots stay consistent.
|
||
function setApiKey(v) {
|
||
setProviderOpt("gemini", "apiKey", v);
|
||
}
|
||
// Legacy: setModel() updated the analysis-model dropdown. The new
|
||
// picker uses setAnalysisModel(). Keep this for back-compat — it
|
||
// updates both the legacy field and the new selection.
|
||
function setModel(m) {
|
||
state.model = m;
|
||
state.analysisModel = m;
|
||
saveProviderSelection();
|
||
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("recap-clips", JSON.stringify(state.clipCollection));
|
||
}
|
||
|
||
function loadClipCollection() {
|
||
try {
|
||
const saved = localStorage.getItem("recap-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));
|
||
}
|
||
// Any paid tier — Pro or Max. Both flip the "free user" gating off.
|
||
function isLicensed() {
|
||
return (
|
||
state.license &&
|
||
state.license.state === "licensed" &&
|
||
(hasEntitlement("pro") || hasEntitlement("max"))
|
||
);
|
||
}
|
||
// Used by the upgrade banner / toolbar to decide whether to show an
|
||
// Upgrade CTA. "Pro tier OR above" means the user has at least the
|
||
// baseline paid feature set (subscriptions + auto-queue). Today
|
||
// both Pro and Max licenses include `subscriptions`, so this is
|
||
// equivalent to "is paid" — kept as a distinct helper so a future
|
||
// sub-Pro tier (e.g. a "starter" license without subscriptions)
|
||
// can drop the subscriptions check and still pass isLicensed().
|
||
function isProTier() {
|
||
return isLicensed() && hasEntitlement("subscriptions");
|
||
}
|
||
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 || "recap",
|
||
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 || "recap",
|
||
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 || "recap"}`;
|
||
}
|
||
|
||
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();
|
||
|
||
// Boot sequence:
|
||
// 1. Fetch admin-status. If the gate is enabled and we're not
|
||
// authed, render the login screen and stop. The user runs
|
||
// initAfterAdminAuth() from submitAdminLogin() once they sign
|
||
// in.
|
||
// 2. Otherwise, kick off the normal license + health loads.
|
||
(async () => {
|
||
try {
|
||
await loadAdminStatus();
|
||
} catch {}
|
||
if (state.admin.enabled && !state.admin.authed) {
|
||
render();
|
||
return;
|
||
}
|
||
await initAfterAdminAuth();
|
||
})();
|
||
|
||
// Everything that used to run unconditionally at boot but is gated
|
||
// by the admin login. Idempotent — safe to call again after a
|
||
// successful login.
|
||
async function initAfterAdminAuth() {
|
||
try {
|
||
const [_, health, net, _job, _discover] = await Promise.all([
|
||
loadLicenseStatus(),
|
||
fetch(`${API_BASE}/api/health`).then(r => r.json()),
|
||
fetch(`${API_BASE}/api/network-mode`).then(r => r.json()).catch(() => null),
|
||
// Survives browser refresh: if the server has a free-tier job
|
||
// running, the banner renders with what's processing + Cancel,
|
||
// and the activity log is repopulated from the server's
|
||
// buffered log entries so a refresh doesn't drop everything
|
||
// the user has already seen.
|
||
loadCurrentJob({ withLogs: true }),
|
||
// Pre-fill picker UI placeholders for providers the server
|
||
// can auto-detect (Ollama via StartOS dependency today).
|
||
loadProviderDiscovery(),
|
||
// Relay credit balance + tier (cached server-side from the
|
||
// last relay call). Surfaces the "N credits remaining" pill
|
||
// near the picker; safe to call before the relay is even
|
||
// configured (returns nulls + configured:false).
|
||
loadRelayStatus(),
|
||
// Per-field server-side credential status so the picker UI
|
||
// can hint at server-configured keys + show Delete buttons
|
||
// even when localStorage is empty.
|
||
loadProviderServerStatus(),
|
||
// Live tier-quota policy from the relay so dynamic copy
|
||
// (activation screen credit count, etc.) reflects the
|
||
// operator's current relay config without a Recap update.
|
||
loadRelayPolicy(),
|
||
]);
|
||
if (state.currentJob) startCurrentJobPoll();
|
||
state.hasServerKey = !!health.hasServerKey;
|
||
state.installId = health.installId || null;
|
||
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;
|
||
}
|
||
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";
|
||
|
||
// Library is free for everyone — load on every boot.
|
||
await loadHistory().catch(() => {});
|
||
if (isLicensed() && 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>
|