feat: inline video player with Vimeo/MP4/YouTube support
All checks were successful
Stuffle/personal-learn/pipeline/head This commit looks good

▶ video button expands a player panel directly below the card:
- Vimeo (player.vimeo.com): iframe embed
- YouTube: iframe embed
- Direct MP4 / local file: <video> element
Click ▶ video to open, ✕ close (or click again) to dismiss.
Playback stops on close (iframe src reset, video.pause()).

No external navigation — user stays on the catalogue page.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
2026-10-06 11:53:54 +05:30
parent af4412f3e7
commit c0bdada7dd

View File

@@ -146,12 +146,28 @@ main { padding: 1.2rem 1.4rem; }
text-decoration: none; border: 1px solid;
}
.jump.video { color: #34d399; border-color: #34d399;
background: rgba(52,211,153,.08); }
background: rgba(52,211,153,.08); cursor: pointer; }
.jump.pdf { color: #f87171; border-color: #f87171;
background: rgba(248,113,113,.08); }
.jump.view { color: var(--teal); border-color: var(--teal);
background: rgba(20,184,166,.08); }
/* ── inline video player ── */
.video-panel {
margin-top: .7rem; border-radius: 8px; overflow: hidden;
background: #000; display: none;
}
.video-panel.open { display: block; }
.video-panel iframe,
.video-panel video {
width: 100%; aspect-ratio: 16/9; border: none; display: block;
}
.video-close {
display: flex; justify-content: flex-end; padding: .3rem .5rem;
background: #111; cursor: pointer; color: var(--dim); font-size: .8rem;
}
.video-close:hover { color: var(--ink); }
.empty { color: var(--dim); padding: 2rem 0; text-align: center; }
</style>
</head>
@@ -272,15 +288,40 @@ function render() {
: '<div class="empty">No artifacts match the current filters.</div>';
}
// Unique ID counter for video panels
let _uid = 0;
function embedHTML(videoUrl) {
if (!videoUrl) return '';
// Vimeo player.vimeo.com or vimeo.com/video/* → iframe embed
if (videoUrl.includes('vimeo.com')) {
// Ensure we use the player embed URL
const src = videoUrl.includes('player.vimeo.com')
? videoUrl
: videoUrl.replace('vimeo.com/', 'player.vimeo.com/video/');
return `<iframe src="${src}" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>`;
}
// YouTube
if (videoUrl.includes('youtube.com') || videoUrl.includes('youtu.be')) {
const id = videoUrl.match(/(?:v=|youtu\.be\/)([^&?]+)/)?.[1];
if (id) return `<iframe src="https://www.youtube.com/embed/${id}" allow="autoplay; fullscreen" allowfullscreen></iframe>`;
}
// Direct MP4 / local file
return `<video src="${videoUrl}" controls preload="none"></video>`;
}
function cardHTML(a) {
const icon = TYPE_ICONS[a.type] || '•';
const badge = `<span class="badge ${a.type}">${a.type}</span>`;
const date = a.date ? `<span>${a.date}</span>` : '';
const course = a.course ? `<span style="color:var(--dim)">${a.course}</span>` : '';
const vid = a.video_url;
const panelId = vid ? `vp-${_uid++}` : '';
const links = [];
if (a.type === 'transcript' && a.video_url) {
links.push(`<a class="jump video" href="${a.video_url}" target="_blank">▶ video</a>`);
if (vid) {
links.push(`<button class="jump video" onclick="toggleVideo('${panelId}',this)">▶ video</button>`);
}
if (a.source_url && a.type === 'pdf') {
links.push(`<a class="jump pdf" href="${a.source_url}" target="_blank">📄 PDF</a>`);
@@ -289,16 +330,52 @@ function cardHTML(a) {
links.push(`<a class="jump view" href="${a.served_url}" target="_blank">view text</a>`);
}
const videoPanel = vid ? `
<div class="video-panel" id="${panelId}">
<div class="video-close" onclick="closeVideo('${panelId}')">✕ close</div>
${embedHTML(vid)}
</div>` : '';
return `<div class="card">
<div class="type-icon">${icon}</div>
<div class="card-body">
<div class="card-title">${esc(a.title || a.id)}</div>
<div class="card-meta">${badge}${date}${course}</div>
${links.length ? '<div class="jump-links">' + links.join('') + '</div>' : ''}
${videoPanel}
</div>
</div>`;
}
function toggleVideo(panelId, btn) {
const panel = document.getElementById(panelId);
if (!panel) return;
const isOpen = panel.classList.contains('open');
if (isOpen) {
closeVideo(panelId);
btn.textContent = '▶ video';
} else {
panel.classList.add('open');
btn.textContent = '▼ video';
// Scroll panel into view smoothly
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function closeVideo(panelId) {
const panel = document.getElementById(panelId);
if (!panel) return;
panel.classList.remove('open');
// Stop playback by clearing src on iframe/video
const iframe = panel.querySelector('iframe');
const video = panel.querySelector('video');
if (iframe) { const s = iframe.src; iframe.src = ''; iframe.src = s; }
if (video) { video.pause(); video.currentTime = 0; }
// Reset button label
const btn = panel.closest('.card-body')?.querySelector('.jump.video');
if (btn) btn.textContent = '▶ video';
}
const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
loadCatalog();