/**
* 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 = `
`;
}
/**
* 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);
});
}
})();