Files
recap/public/index.html
T
Keysat 373d10595b Pluggable AI providers, relay credit system, picker UX overhaul
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.
2026-05-11 23:46:20 -05:00

5947 lines
278 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>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 &amp; 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 &rarr;</a>
<button class="activation-link"
style="background:none;border:none;color:#94a3b8;cursor:pointer;padding:0;font-size:13px;"
onclick="dismissActivation()">
Skip &mdash; use free mode
</button>
</div>
<div class="activation-meta">
Product: <strong>${escHtml(lic.productSlug || "recap")}</strong>
${lic.keysatBaseUrl ? ` &middot; 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>
&middot; ${escHtml(what)}
<span style="color:#94a3b8;">&middot; <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 &middot; bring your own API key &middot; upgrade for auto-queue, clips, and relay credits";
} else if (!isProTier()) {
label = "Paid license";
descr = "your license is missing some paid features &mdash; 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>
&middot; ${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;">&middot; ${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>&middot; Tier: <strong style="color:#e2e8f0;">${tierLabel}</strong></span>
<span>&middot; ${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) &middot; 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 &ldquo;✓ Server-configured&rdquo; 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 &mdash; leave blank to use it</div>`;
} else if (!localUrl && urlOnServer) {
inner += `<div style="font-size:10px;color:#86efac;">✓ Server-configured via StartOS action &mdash; 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 &mdash; 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(" &middot; ")} &middot; <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;">&middot; ${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)} &middot; Pro feature</div>
<div class="pro-desc">${escHtml(description)}</div>
<a class="pro-cta" href="${escHtml(upgradeToProUrl())}" target="_blank" rel="noopener">Upgrade to Pro &rarr;</a>
</div>
`;
}
function renderSettingsModal() {
return `
<div class="settings-overlay" onclick="if(event.target===this)toggleSettings()">
<div class="settings-modal">
<div class="settings-modal-header">
<h2>Settings</h2>
<button class="close-btn" onclick="toggleSettings()">&times;</button>
</div>
<div class="settings-modal-body">
${renderLicenseBlock()}
${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 &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
</div>
<button class="expand-btn" onclick="toggleVideoMinimize()" title="${state.videoMinimized ? "Show player" : "Hide player"}" style="margin-right:4px;">
${state.videoMinimized ? "Show Player" : "Hide Player"}
</button>
<button class="expand-btn" onclick="toggleExpandAll()">
${state.expandAll ? "Collapse All" : "Expand All"}
</button>
</div>
${!state.videoMinimized && state.url ? `
<audio id="podcast-audio" preload="none" style="width:100%; height:36px; margin-bottom:12px; border-radius:8px; outline:none;"
controls src="${escHtml(state.url)}"></audio>
` : ""}
<div style="border-top:1px solid #1e293b; padding-top:12px;"></div>
<div class="chunks-scroll" style="max-height: calc(100vh - ${state.videoMinimized ? "200" : "260"}px);">
${state.chunks.map((chunk, i) => renderChunk(chunk, i)).join("")}
</div>
</div>
`;
}
return `
<div class="results-split">
<button class="landscape-back" onclick="screen.orientation.lock('portrait-primary').catch(()=>{})" title="Exit fullscreen">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"></polyline></svg>
</button>
<div class="results-left ${state.videoMinimized ? "minimized" : ""}" style="position:relative;">
${state.videoId ? `
<button class="minimize-toggle" onclick="toggleVideoMinimize()" title="Minimize video">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 14 10 14 10 20"></polyline><polyline points="20 10 14 10 14 4"></polyline><line x1="14" y1="10" x2="21" y2="3"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</button>
<div class="video-mini-bar" onclick="toggleVideoMinimize()">
<span class="mini-btn" onclick="event.stopPropagation(); togglePlayPause()" title="Play/Pause">
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><polygon points="5,3 19,12 5,21"></polygon></svg>
</span>
<span class="mini-title">${escHtml(state.videoTitle || "Video")}</span>
<span class="mini-btn" title="Expand video">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 3 21 3 21 9"></polyline><polyline points="9 21 3 21 3 15"></polyline><line x1="21" y1="3" x2="14" y2="10"></line><line x1="3" y1="21" x2="10" y2="14"></line></svg>
</span>
</div>
<div class="video-embed">
<div id="yt-player"></div>
</div>
${state.videoTitle ? `<div class="video-title">${escHtml(state.videoTitle)}</div>` : ""}
<div class="video-meta">${state.chunks.length} topics &middot; ${totalEntries} segments &middot; ${formatTime(totalDuration)} total</div>
${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()">&times;</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)} &middot; ${h.topicCount} topics</div>
</div>
<button class="history-action-small" onclick="event.stopPropagation(); exportSessionPDF('${h.id}')" title="Export PDF">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline></svg>
</button>
<button class="history-delete" onclick="deleteSession('${h.id}', event)" title="Delete">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
</div>
`;
}
function renderHistorySidebar() {
const totalSessions = Object.keys(state.historySessions).length;
const { folders, uncategorized } = state.historyMeta;
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">&times;</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">&times;</button>' +
'</div>' +
noteHtml +
'</div>';
}
clipListHtml += '</div>';
}
}
return `
<div class="settings-overlay" onclick="toggleClipPanel()">
<div class="settings-modal" onclick="event.stopPropagation()" style="max-width: 560px;">
<div class="settings-modal-header">
<h2>📎 Clip Collection (${clips.length})</h2>
<div style="display:flex; gap:8px;">
${clips.length > 0 ? `
<button class="expand-btn" onclick="exportClipCollectionPDF()" style="font-size:11px; padding:5px 12px;">Export PDF</button>
<button class="expand-btn" onclick="clearClipCollection()" style="font-size:11px; padding:5px 12px; color:#f87171;">Clear All</button>
` : ""}
<button class="close-btn" onclick="toggleClipPanel()">&times;</button>
</div>
</div>
<div class="settings-modal-body" style="max-height: 60vh; overflow-y: auto;">
${clipListHtml}
</div>
</div>
</div>
`;
}
// ── PDF Export & Clip Collection ────────────────────────────────────────
async function exportSessionPDF(sessionId) {
try {
const res = await fetch(`${API_BASE}/api/history/${sessionId}`);
const data = await res.json();
if (!data.chunks || data.chunks.length === 0) { showToast("No data to export", "⚠", 3000); return; }
buildPDF(data.title || "Untitled", data.videoId, data.chunks, data.type || "youtube");
} catch (e) {
showToast("Export failed: " + e.message, "✕", 4000);
}
}
function exportCurrentPDF() {
if (!state.chunks.length) return;
buildPDF(state.videoTitle || "Untitled", state.videoId, state.chunks, state.currentType);
}
// ── Shared PDF helpers ──────────────────────────────────────────────
function createPDFDoc() {
const { jsPDF } = window.jspdf;
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pw = doc.internal.pageSize.getWidth();
const ph = doc.internal.pageSize.getHeight();
const margin = 20;
const maxW = pw - margin * 2;
let y = margin;
let pageNum = 1;
// Colors
const C = {
title: [15, 23, 42], // near-black
heading: [30, 41, 59], // dark slate
body: [51, 65, 85], // medium slate
meta: [100, 116, 139], // gray
light: [148, 163, 184], // light gray
link: [37, 99, 235], // blue
accent: [99, 102, 241], // indigo
noteBg: [248, 250, 252], // very light gray
noteBar: [99, 102, 241], // indigo bar
divider: [226, 232, 240], // light border
};
function addFooter() {
doc.setFont("helvetica", "normal"); doc.setFontSize(7); doc.setTextColor(...C.light);
doc.text("YouTube Transcript Summarizer", margin, ph - 8);
doc.text("Page " + pageNum, pw - margin, ph - 8, { align: "right" });
}
function checkPage(needed) {
if (y + needed > ph - 16) {
addFooter();
doc.addPage(); y = margin; pageNum++;
return true;
}
return false;
}
// Draw underlined link text
function drawLink(text, x, yPos, url) {
doc.setTextColor(...C.link);
doc.textWithLink(text, x, yPos, { url });
// Underline
const tw = doc.getTextWidth(text);
doc.setDrawColor(...C.link); doc.setLineWidth(0.2);
doc.line(x, yPos + 0.8, x + tw, yPos + 0.8);
}
// Draw a shaded note box with left accent bar
function drawNoteBox(noteText, x, boxWidth) {
const noteLines = doc.splitTextToSize(noteText, boxWidth - 14);
const lineH = 4;
const boxH = noteLines.length * lineH + 8;
checkPage(boxH + 4);
// Background fill
doc.setFillColor(...C.noteBg);
doc.roundedRect(x, y - 2, boxWidth, boxH, 2, 2, "F");
// Left accent bar
doc.setFillColor(...C.noteBar);
doc.rect(x, y - 2, 1.5, boxH, "F");
// Note label
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
doc.setTextColor(...C.accent);
doc.text("MY NOTE", x + 6, y + 2);
// Note text
doc.setFont("helvetica", "normal"); doc.setFontSize(9);
doc.setTextColor(...C.heading);
let ny = y + 6;
noteLines.forEach(line => { doc.text(line, x + 6, ny); ny += lineH; });
y += boxH + 3;
}
return { doc, pw, ph, margin, maxW, C, checkPage, addFooter, drawLink, drawNoteBox, getY: () => y, setY: (v) => { y = v; }, addY: (v) => { y += v; } };
}
function buildPDF(title, videoId, chunks, type) {
if (!window.jspdf) {
showToast("PDF library not loaded yet — please try again in a moment", "⚠", 4000);
return;
}
try {
const p = createPDFDoc();
const { doc, pw, margin, maxW, C, checkPage, addFooter, drawLink } = p;
// ── Header ──
doc.setFont("helvetica", "bold"); doc.setFontSize(20); doc.setTextColor(...C.title);
const titleLines = doc.splitTextToSize(title, maxW);
titleLines.forEach(line => { checkPage(9); doc.text(line, margin, p.getY()); p.addY(8); });
p.addY(2);
doc.setFont("helvetica", "normal"); doc.setFontSize(9); doc.setTextColor(...C.meta);
doc.text(chunks.length + " topics | Generated " + new Date().toLocaleDateString(), margin, p.getY());
p.addY(8);
// Accent line under header
doc.setDrawColor(...C.accent); doc.setLineWidth(0.6);
doc.line(margin, p.getY(), margin + 40, p.getY()); p.addY(8);
// ── Chunks ──
chunks.forEach((chunk, ci) => {
checkPage(22);
const startSec = Math.floor(chunk.startTime);
const ts = formatTime(chunk.startTime);
const endEntry = chunk.entries[chunk.entries.length - 1];
const endTs = formatTime(endEntry ? endEntry.offset : chunk.startTime);
const ytUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + startSec : null;
// Topic number pill
doc.setFont("helvetica", "bold"); doc.setFontSize(8); doc.setTextColor(...C.accent);
doc.text("TOPIC " + (ci + 1), margin, p.getY());
// Time range
doc.setFont("helvetica", "normal"); doc.setFontSize(8); doc.setTextColor(...C.meta);
doc.text(ts + " \u2013 " + endTs, margin + 22, p.getY());
p.addY(5);
// Topic title
doc.setFont("helvetica", "bold"); doc.setFontSize(12); doc.setTextColor(...C.heading);
const titleL = doc.splitTextToSize(chunk.title, maxW);
titleL.forEach((line, li) => {
checkPage(6);
if (li === 0 && ytUrl) {
drawLink(line, margin, p.getY(), ytUrl);
} else {
doc.text(line, margin, p.getY());
}
p.addY(5.5);
});
p.addY(2);
// Summary
doc.setFont("helvetica", "italic"); doc.setFontSize(9.5); doc.setTextColor(...C.body);
const sumLines = doc.splitTextToSize(chunk.summary, maxW);
sumLines.forEach(line => { checkPage(5); doc.text(line, margin, p.getY()); p.addY(4.2); });
p.addY(3);
// Transcript entries
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
chunk.entries.forEach(entry => {
checkPage(5);
const ets = formatTime(entry.offset);
const sec = Math.floor(entry.offset);
const entryUrl = (type === "youtube" && videoId) ? "https://youtube.com/watch?v=" + videoId + "&t=" + sec : null;
// Timestamp
if (entryUrl) {
doc.setFont("helvetica", "bold"); doc.setFontSize(8);
drawLink("[" + ets + "]", margin + 2, p.getY(), entryUrl);
doc.setFont("helvetica", "normal"); doc.setFontSize(8.5); doc.setTextColor(...C.body);
// Text after timestamp
const textLines = doc.splitTextToSize(entry.text, maxW - 20);
textLines.forEach((line, li) => {
if (li === 0) {
doc.text(line, margin + 18, p.getY());
} else {
checkPage(3.8);
doc.text(line, margin + 18, p.getY());
}
p.addY(3.6);
});
} else {
const entryText = "[" + ets + "] " + entry.text;
const entryLines = doc.splitTextToSize(entryText, maxW - 4);
entryLines.forEach(line => { checkPage(3.8); doc.text(line, margin + 2, p.getY()); p.addY(3.6); });
}
});
p.addY(4);
// Separator
if (ci < chunks.length - 1) {
checkPage(6);
doc.setDrawColor(...C.divider); doc.setLineWidth(0.15);
doc.line(margin, p.getY(), pw - margin, p.getY()); p.addY(6);
}
});
addFooter();
const safeName = title.replace(/[^a-zA-Z0-9_\- ]/g, "").trim().substring(0, 60) || "transcript";
doc.save(safeName + ".pdf");
showToast("PDF exported", "📄");
} catch (err) {
console.error("PDF export error:", err);
showToast("PDF export failed: " + err.message, "✕", 5000);
}
}
// ── Clip Collection ───────────────────────────────────────────────────────
function addToClipCollection(sessionId, chunkIndex, entryIndex) {
// Pro-tier feature. Defense-in-depth: even if a Core user reaches this
// (e.g. via stale UI before re-render), refuse the add and prompt upgrade.
if (!hasEntitlement("clips")) {
showToast("Clips are a Pro feature. Upgrade to unlock.", "🔒", 3500);
return;
}
// Check if already in collection
const exists = state.clipCollection.find(c =>
c.sessionId === sessionId && c.chunkIndex === chunkIndex &&
(entryIndex === undefined ? c.entryIndex === null : c.entryIndex === entryIndex)
);
if (exists) {
showToast("Already in clip collection", "", 2000);
return;
}
// Show notes prompt modal
showClipNotePrompt(sessionId, chunkIndex, entryIndex !== undefined ? entryIndex : null);
}
function showClipNotePrompt(sessionId, chunkIndex, entryIndex) {
// Create overlay
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>📎 Add Clip</h2>' +
'<button class="close-btn" id="clip-note-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<label class="field-label" style="margin-top:0">Note (optional)</label>' +
'<textarea id="clip-note-input" rows="3" placeholder="Why is this interesting? Add context for when you share it..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;"></textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-note-skip" class="expand-btn" style="font-size:12px; padding:8px 16px;">Skip</button>' +
'<button id="clip-note-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Add Clip</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-note-input");
setTimeout(() => input.focus(), 50);
function finish(note) {
state.clipCollection.push({
sessionId,
chunkIndex,
entryIndex,
note: (note || "").trim() || null,
});
saveClipCollection();
overlay.remove();
showToast("Clip added (" + state.clipCollection.length + " total)", "📎");
render();
}
function cancel() { overlay.remove(); }
document.getElementById("clip-note-save").onclick = () => finish(input.value);
document.getElementById("clip-note-skip").onclick = () => finish("");
document.getElementById("clip-note-cancel").onclick = cancel;
overlay.onclick = cancel;
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); finish(input.value); } };
}
function editClipNote(index) {
const clip = state.clipCollection[index];
if (!clip) return;
const overlay = document.createElement("div");
overlay.className = "settings-overlay";
overlay.style.zIndex = "2000";
overlay.innerHTML = '<div class="settings-modal" style="max-width:440px;" onclick="event.stopPropagation()">' +
'<div class="settings-modal-header">' +
'<h2>✏️ Edit Note</h2>' +
'<button class="close-btn" id="clip-edit-cancel">&times;</button>' +
'</div>' +
'<div class="settings-modal-body">' +
'<textarea id="clip-edit-input" rows="3" placeholder="Add your thoughts about this clip..." ' +
'style="width:100%; padding:10px 14px; font-size:13px; border:1px solid #1e293b; border-radius:8px; ' +
'background:#0f172a; color:#e2e8f0; resize:vertical; font-family:inherit; outline:none; line-height:1.5;">' +
escHtml(clip.note || "") + '</textarea>' +
'<div style="display:flex; gap:8px; justify-content:flex-end; margin-top:14px;">' +
'<button id="clip-edit-save" class="submit-btn" style="font-size:12px; padding:8px 20px;">Save</button>' +
'</div>' +
'</div>' +
'</div>';
document.body.appendChild(overlay);
const input = document.getElementById("clip-edit-input");
setTimeout(() => { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }, 50);
function save() {
state.clipCollection[index].note = input.value.trim() || null;
saveClipCollection();
overlay.remove();
render();
}
document.getElementById("clip-edit-save").onclick = save;
document.getElementById("clip-edit-cancel").onclick = () => overlay.remove();
overlay.onclick = () => overlay.remove();
input.onkeydown = (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); save(); } };
}
function removeFromClipCollection(index) {
state.clipCollection.splice(index, 1);
saveClipCollection();
render();
}
function clearClipCollection() {
state.clipCollection = [];
state.clipPanelOpen = false;
saveClipCollection();
render();
showToast("Clip collection cleared", "🗑");
}
function saveClipCollection() {
localStorage.setItem("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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}
// ── Keysat license helpers ───────────────────────────────────────────────
function hasEntitlement(name) {
return !!(state.license && state.license.entitlements && state.license.entitlements.includes(name));
}
// 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>