Files
playground/youtube.html
T

366 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YouTube Video Summarizer</title>
<style>
:root {
color-scheme: light dark;
--bg: #fff;
--bg-alt: #f5f5f5;
--text: #000;
--text-muted: #666;
--border: #ddd;
--accent: #4285f4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--bg-alt: #2a2a2a;
--text: #fff;
--text-muted: #aaa;
--border: #444;
--accent: #5a9fff;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: "JetBrains Mono", monospace;
line-height: 1.6;
font-size: 16px;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
background: var(--bg);
color: var(--text);
transition:
background 0.2s,
color 0.2s;
}
.container {
padding: 0;
}
.form-group {
display: none;
}
.loading {
text-align: center;
color: var(--text-muted);
margin: 2rem 0;
}
.spinner {
display: inline-block;
width: 1rem;
height: 1rem;
border: 3px solid var(--bg-alt);
border-top: 3px solid var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 0.5rem;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
#summary {
margin-top: 0;
padding: 0;
}
#summary :is(ul, ol) {
margin: 1rem 0;
padding-left: 1.5rem;
}
#summary :is(ul, ol) :is(ul, ol) {
padding-left: 1.5rem;
margin: 0.5rem 0;
}
#summary li {
margin-bottom: 0.5rem;
}
#summary blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
margin: 1rem 0;
color: var(--text-muted);
font-style: italic;
}
#summary h2,
#summary h3 {
margin: 1.5rem 0 0.5rem 0;
}
.error {
background: #c33;
color: #fff;
padding: 1rem;
border-radius: 4px;
margin: 1rem 0;
}
</style>
</head>
<body>
<div class="container">
<div class="form-group">
<input type="text" id="videoUrl" placeholder="https://www.youtube.com/watch?v=..." />
<textarea id="video" placeholder="Video transcript"></textarea>
</div>
<button id="summarizeBtn" style="display: none"></button>
<div id="loading" class="loading" style="display: none">
<div class="spinner"></div>
Generating summary...
</div>
<div id="error" class="error" style="display: none"></div>
<div id="summary"></div>
</div>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script>
const videoUrlInput = document.getElementById("videoUrl");
const textInput = document.getElementById("text");
const summarizeBtn = document.getElementById("summarizeBtn");
const loadingDiv = document.getElementById("loading");
const errorDiv = document.getElementById("error");
const summaryDiv = document.getElementById("summary");
// Pre-fill with YouTube URL if available and auto-submit
if (window.env?.YOUTUBE_URL || window.env?.YOUTUBE_TRANSCRIPT) {
if (window.env?.YOUTUBE_URL) videoUrlInput.value = window.env.YOUTUBE_URL;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () => summarizeBtn.click());
} else {
summarizeBtn.click();
}
}
summarizeBtn.addEventListener("click", async () => {
const url = videoUrlInput.value.trim();
const transcript = window.env?.YOUTUBE_TRANSCRIPT;
if (!transcript && !url) {
showError("Please enter a YouTube URL");
return;
}
if (!transcript) {
// Validate YouTube URL
const youtubeUrlPattern = /^https?:\/\/(www\.)?youtube\.com\/watch\?v=.+$|^https?:\/\/youtu\.be\/.+$/;
if (!youtubeUrlPattern.test(url)) {
showError("Please enter a valid YouTube URL");
return;
}
}
await summarize(url, transcript);
});
function showError(message) {
errorDiv.textContent = message;
errorDiv.style.display = "block";
}
function hideError() {
errorDiv.style.display = "none";
errorDiv.textContent = "";
}
async function summarize(url, transcript) {
hideError();
summaryDiv.innerHTML = "";
loadingDiv.style.display = "block";
summarizeBtn.disabled = true;
const prompt = `
Give me keypoints of this video transcript, with enough context, also with quotes from the content.
Only the summary please.
Skip promotions.
Do not censor profanity.
Formatted in valid markdown without any wrappers or code blocks.
`;
let fullResponse = "";
const abortController = new AbortController();
try {
await chatWithGemini({
prompt: prompt,
messages: [{ role: "user", content: transcript ?? url }],
model: "gemini-3-flash-preview",
onContent: (text) => {
fullResponse += text;
summaryDiv.innerHTML = markdownToHtml(fullResponse);
},
onError: (err) => {
showError(`Error: ${err.message}`);
loadingDiv.style.display = "none";
summarizeBtn.disabled = false;
},
signal: abortController.signal,
});
loadingDiv.style.display = "none";
} catch (err) {
showError(`Error: ${err.message}`);
loadingDiv.style.display = "none";
} finally {
summarizeBtn.disabled = false;
}
}
const md = markdownit({ html: false });
function markdownToHtml(markdown) {
return md.render(markdown);
}
async function chatWithGemini({ prompt, onContent, onError, messages, signal, apiKey = window.env.GEMINI_API_KEY, model }) {
const contents = messages.map((msg, i, arr) => {
const role = msg.role === "assistant" ? "model" : "user";
let parts = [{ text: msg.content }];
// Add YouTube URLs as file data for the final user message
const isLastMessage = i === arr.length - 1;
if (isLastMessage && role === "user") {
const youtubeUrlPattern = /https?:\/\/(www\.)?youtube\.com\/watch\?v=[\w-]+|https?:\/\/youtu\.be\/[\w-]+/g;
const youtubeUrls = msg.content.match(youtubeUrlPattern) || [];
const fileParts = youtubeUrls.map((url) => ({ file_data: { file_uri: url } }));
parts = [...parts, ...fileParts];
}
return {
role,
parts,
};
});
try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...(prompt ? { system_instruction: { parts: [{ text: prompt }] } } : {}),
contents,
generationConfig: {
thinkingConfig: { thinkingLevel: "low" },
},
}),
signal,
});
if (!response.ok) {
const text = await response.text();
await onError?.(new Error(`Gemini API Error ${response.status}: ${text}`));
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let pos = 0;
while (pos < buffer.length) {
// Find start of JSON object
const start = buffer.indexOf("{", pos);
if (start === -1) break;
// Find matching closing brace
let depth = 0;
let inString = false;
let escaped = false;
let end = -1;
for (let i = start; i < buffer.length; i++) {
const ch = buffer[i];
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === '"') {
inString = !inString;
continue;
}
if (!inString) {
if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) {
end = i;
break;
}
}
}
}
if (end === -1) {
// Incomplete JSON, keep it in buffer
buffer = buffer.slice(start);
break;
}
// Parse and process JSON
try {
const json = JSON.parse(buffer.slice(start, end + 1));
const parts = json.candidates?.[0]?.content?.parts || [];
const text = parts
.filter((p) => p.text)
.map((p) => p.text)
.join("");
if (text) await onContent(text);
} catch (e) {
// Ignore parse errors
}
pos = end + 1;
}
// Keep only unparsed remainder
buffer = buffer.slice(pos);
}
} catch (err) {
if (err.name !== "AbortError") await onError?.(err);
}
}
</script>
</body>
</html>