/** * Video Player Hydration for MkDocs * * Automatically converts .video-block placeholders into actual video players * when landing pages with video blocks are exported to MkDocs. * * Supports both standard HTML5 video and advanced player with reactions. */ (function() { 'use strict'; // Configuration const MEDIA_API_URL = window.MEDIA_API_URL || 'http://localhost:4100'; const PUBLIC_URL = window.PUBLIC_URL || 'http://localhost:3000'; /** * Fetch video metadata from Media API */ async function fetchVideoMetadata(videoId) { try { const response = await fetch(`${MEDIA_API_URL}/api/videos/${videoId}/metadata`); if (!response.ok) { throw new Error(`Failed to fetch video ${videoId}: ${response.statusText}`); } return await response.json(); } catch (error) { console.error(`Error fetching video ${videoId} metadata:`, error); return null; } } /** * Create standard HTML5 video player */ function createStandardPlayer(metadata, options) { const video = document.createElement('video'); video.src = `${MEDIA_API_URL}/api/videos/${metadata.id}/stream`; video.poster = `${MEDIA_API_URL}/api/videos/${metadata.id}/thumbnail`; video.controls = options.controls !== false; video.autoplay = options.autoplay === true; video.loop = options.loop === true; video.muted = options.muted === true; video.style.width = options.width || '100%'; video.style.height = options.height || 'auto'; video.style.maxWidth = '100%'; video.style.borderRadius = '8px'; video.style.display = 'block'; // Add title attribute for accessibility video.setAttribute('title', metadata.title); video.setAttribute('aria-label', metadata.title); return video; } /** * Create advanced player container with reactions * Note: Full reaction functionality requires React components, * so this is a simplified version for MkDocs */ function createAdvancedPlayer(metadata, options) { const container = document.createElement('div'); container.className = 'advanced-video-container'; container.style.maxWidth = options.width || '100%'; container.style.margin = '0 auto'; // Video player const video = createStandardPlayer(metadata, options); container.appendChild(video); // Reaction placeholder (simplified for MkDocs) if (options.showReactions !== false) { const reactionBar = document.createElement('div'); reactionBar.className = 'video-reactions'; reactionBar.style.marginTop = '12px'; reactionBar.style.padding = '12px'; reactionBar.style.background = '#f5f5f5'; reactionBar.style.borderRadius = '8px'; reactionBar.style.textAlign = 'center'; reactionBar.style.fontSize = '14px'; reactionBar.style.color = '#666'; reactionBar.innerHTML = `

View full player with reactions →

`; container.appendChild(reactionBar); } // Video metadata const metaInfo = document.createElement('div'); metaInfo.className = 'video-meta'; metaInfo.style.marginTop = '8px'; metaInfo.style.fontSize = '13px'; metaInfo.style.color = '#999'; metaInfo.innerHTML = ` ${escapeHtml(metadata.title)} ${metadata.durationSeconds ? `• ${formatDuration(metadata.durationSeconds)}` : ''} ${metadata.width && metadata.height ? `• ${metadata.width}×${metadata.height}` : ''} `; container.appendChild(metaInfo); return container; } /** * Render a video block */ async function renderVideoBlock(blockElement) { const videoId = parseInt(blockElement.dataset.videoId, 10); const playerType = blockElement.dataset.playerType || 'standard'; const width = blockElement.dataset.width || '100%'; const height = blockElement.dataset.height || 'auto'; const autoplay = blockElement.dataset.autoplay === 'true'; const controls = blockElement.dataset.controls !== 'false'; const showReactions = blockElement.dataset.showReactions !== 'false'; if (!videoId || isNaN(videoId)) { console.warn('Invalid video ID in video block:', blockElement); showError(blockElement, 'Invalid video ID'); return; } // Show loading state blockElement.innerHTML = '
Loading video...
'; // Fetch metadata const metadata = await fetchVideoMetadata(videoId); if (!metadata) { showError(blockElement, `Video not found (ID: ${videoId})`); return; } // Create player const options = { width, height, autoplay, controls, showReactions }; const player = playerType === 'advanced' ? createAdvancedPlayer(metadata, options) : createStandardPlayer(metadata, options); // Replace placeholder with player blockElement.innerHTML = ''; blockElement.appendChild(player); console.log(`Rendered ${playerType} video player for video ${videoId}`); } /** * Show error state */ function showError(blockElement, message) { blockElement.innerHTML = `

${escapeHtml(message)}

`; } /** * Format duration in seconds to MM:SS */ function formatDuration(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } /** * Escape HTML to prevent XSS */ function escapeHtml(unsafe) { return String(unsafe) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** * Format view count (e.g. 1200 → "1.2K views") */ function formatViewCount(count) { if (!count || count <= 0) return '0 views'; if (count === 1) return '1 view'; if (count < 1000) return count + ' views'; if (count < 1000000) return (count / 1000).toFixed(1).replace(/\.0$/, '') + 'K views'; return (count / 1000000).toFixed(1).replace(/\.0$/, '') + 'M views'; } /** * Get the gallery URL for a video. */ function getGalleryUrl(videoId) { return PUBLIC_URL + '/gallery?expanded=' + videoId; } /** * Hydrate a video card block: * 1. Fix thumbnail URL using MEDIA_API_URL * 2. Fetch live metadata (views, title) * 3. Clicking thumbnail plays video inline * 4. Replace "Watch →" with Gallery link */ async function hydrateVideoCard(cardElement) { var videoId = parseInt(cardElement.dataset.videoId, 10); if (!videoId || isNaN(videoId)) return; var title = cardElement.dataset.videoTitle || 'Video'; // 1. Fix thumbnail URL — replace placeholder/broken src with MEDIA_API_URL var img = cardElement.querySelector('img'); if (img) { // Cache-buster avoids stale CORS-error cached responses var correctThumbUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/thumbnail?v=' + Date.now(); // Always replace src — data URI placeholder, Docker hostname, or stale URL img.src = correctThumbUrl; // Fallback if thumbnail fails to load — show a styled placeholder instead of broken image img.onerror = function() { img.style.display = 'none'; }; } // 2. Fetch live metadata to update views/title try { var metadata = await fetchVideoMetadata(videoId); if (metadata) { var viewsEl = cardElement.querySelector('[data-role="views"]'); if (viewsEl && metadata.viewCount !== undefined) { viewsEl.textContent = formatViewCount(metadata.viewCount); } } } catch (e) { // Non-critical — card works fine without live metadata } // 3. Prevent the tag from navigating var link = cardElement.querySelector('a'); if (link) { link.addEventListener('click', function(e) { e.preventDefault(); }); } // 4. Fix the play button overlay SVG + set up click-to-play var thumbContainer = cardElement.querySelector('a > div:first-child'); if (thumbContainer) { // Replace old tiny play icon with proper triangle var playOverlay = thumbContainer.querySelector('div:last-child'); if (playOverlay && playOverlay.querySelector('svg')) { playOverlay.innerHTML = ''; } thumbContainer.style.cursor = 'pointer'; thumbContainer.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); // Don't re-create if already playing if (cardElement.querySelector('video')) return; var streamUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/stream'; var thumbUrl = MEDIA_API_URL + '/api/videos/' + videoId + '/thumbnail'; // Build inline player — locked to same 16:9 aspect ratio as the card var playerContainer = document.createElement('div'); playerContainer.className = 'video-card-player'; playerContainer.style.cssText = 'max-width:480px;margin:0 auto;border-radius:12px;overflow:hidden;background:#0d1b2a;box-shadow:0 4px 12px rgba(0,0,0,0.3);'; // 16:9 aspect ratio wrapper prevents container resize when video loads var videoWrapper = document.createElement('div'); videoWrapper.style.cssText = 'position:relative;padding-bottom:56.25%;background:#000;'; var video = document.createElement('video'); video.src = streamUrl; video.poster = thumbUrl; video.controls = true; video.autoplay = true; video.style.cssText = 'position:absolute;top:0;left:0;width:100%;height:100%;display:block;border-radius:12px 12px 0 0;'; video.setAttribute('title', title); videoWrapper.appendChild(video); var infoBar = document.createElement('div'); infoBar.style.cssText = 'padding:10px 16px;background:#1b2838;display:flex;align-items:center;gap:8px;'; var titleSpan = document.createElement('span'); titleSpan.style.cssText = 'color:#fff;font-size:14px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0;'; titleSpan.textContent = title; var galleryLink = document.createElement('a'); galleryLink.href = getGalleryUrl(videoId); galleryLink.target = '_blank'; galleryLink.innerHTML = '▶ Gallery'; galleryLink.className = 'video-card-gallery-btn'; galleryLink.style.cssText = 'background:#9d4edd;border:none;color:#fff;font-size:12px;font-weight:600;padding:5px 12px;border-radius:4px;cursor:pointer;white-space:nowrap;flex-shrink:0;transition:background 0.15s;text-decoration:none;display:inline-block;'; galleryLink.title = 'Open in gallery'; galleryLink.onmouseenter = function() { galleryLink.style.background = '#b06ce6'; }; galleryLink.onmouseleave = function() { galleryLink.style.background = '#9d4edd'; }; galleryLink.addEventListener('click', function(ev) { ev.stopPropagation(); }); var closeBtn = document.createElement('button'); closeBtn.textContent = '\u2715'; closeBtn.style.cssText = 'background:none;border:none;color:#8899aa;font-size:16px;cursor:pointer;padding:4px;flex-shrink:0;transition:color 0.15s;'; closeBtn.title = 'Close player'; closeBtn.onmouseenter = function() { closeBtn.style.color = '#fff'; }; closeBtn.onmouseleave = function() { closeBtn.style.color = '#8899aa'; }; infoBar.appendChild(titleSpan); infoBar.appendChild(galleryLink); infoBar.appendChild(closeBtn); playerContainer.appendChild(videoWrapper); playerContainer.appendChild(infoBar); // Save original card content and replace var originalContent = cardElement.innerHTML; cardElement.innerHTML = ''; cardElement.appendChild(playerContainer); // Close button restores original card closeBtn.addEventListener('click', function(ev) { ev.stopPropagation(); video.pause(); video.src = ''; cardElement.innerHTML = originalContent; hydrateVideoCard(cardElement); }); }); } // 5. Replace "Watch →" text with a Gallery button in the card info bar var infoBar = cardElement.querySelector('a > div:last-child'); if (infoBar) { var flexRow = infoBar.querySelector('div:last-child'); if (flexRow) { var spans = flexRow.querySelectorAll('span'); var watchSpan = null; for (var i = 0; i < spans.length; i++) { if (spans[i].textContent.indexOf('Watch') !== -1) { watchSpan = spans[i]; break; } } if (watchSpan) { var galleryLink = document.createElement('a'); galleryLink.href = getGalleryUrl(videoId); galleryLink.target = '_blank'; galleryLink.innerHTML = '▶ Gallery'; galleryLink.className = 'video-card-gallery-btn'; galleryLink.style.cssText = 'background:#9d4edd;border:none;color:#fff;font-size:12px;font-weight:600;padding:5px 14px;border-radius:4px;cursor:pointer;transition:background 0.15s;text-decoration:none;display:inline-block;'; galleryLink.title = 'Open in gallery'; galleryLink.onmouseenter = function() { galleryLink.style.background = '#b06ce6'; }; galleryLink.onmouseleave = function() { galleryLink.style.background = '#9d4edd'; }; galleryLink.addEventListener('click', function(ev) { ev.stopPropagation(); // Don't trigger card click-to-play }); watchSpan.replaceWith(galleryLink); } } } // Mark as hydrated cardElement.dataset.hydrated = 'true'; console.log('Hydrated video card for video ' + videoId); } /** * Initialize all video blocks on page load */ function initVideoBlocks() { var blocks = document.querySelectorAll('.video-block'); console.log('Found ' + blocks.length + ' video block(s) to hydrate'); blocks.forEach(function(block) { // Skip if already rendered (has video element) if (block.querySelector('video')) { console.log('Video block already rendered, skipping'); return; } renderVideoBlock(block); }); } /** * Initialize all video card blocks on page load */ function initVideoCards() { var cards = document.querySelectorAll('.video-card-block'); console.log('Found ' + cards.length + ' video card(s) to hydrate'); cards.forEach(function(card) { // Skip if already hydrated if (card.dataset.hydrated === 'true') return; hydrateVideoCard(card); }); } /** * Initialize all video elements (blocks + cards) */ function initAll() { initVideoBlocks(); initVideoCards(); } // Initialize when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', initAll); } else { initAll(); } // Re-initialize on MkDocs navigation (for SPA-style navigation) if (typeof window.document$ !== 'undefined') { window.document$.subscribe(function() { console.log('MkDocs navigation detected, re-initializing video elements'); setTimeout(initAll, 100); }); } })();