225 lines
7.4 KiB
JavaScript
225 lines
7.4 KiB
JavaScript
/**
|
||
* 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 = `
|
||
<p style="margin: 0;">
|
||
<a href="${PUBLIC_URL}/media/${metadata.id}" target="_blank" style="color: #1890ff; text-decoration: none;">
|
||
View full player with reactions →
|
||
</a>
|
||
</p>
|
||
`;
|
||
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 = `
|
||
<strong style="color: #333;">${escapeHtml(metadata.title)}</strong>
|
||
${metadata.durationSeconds ? `<span style="margin-left: 8px;">• ${formatDuration(metadata.durationSeconds)}</span>` : ''}
|
||
${metadata.width && metadata.height ? `<span style="margin-left: 8px;">• ${metadata.width}×${metadata.height}</span>` : ''}
|
||
`;
|
||
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 = '<div class="video-loading" style="text-align: center; padding: 40px; color: #999;">Loading video...</div>';
|
||
|
||
// 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 = `
|
||
<div class="video-error" style="
|
||
padding: 40px;
|
||
text-align: center;
|
||
background: #fff3f3;
|
||
border: 1px solid #ffccc7;
|
||
border-radius: 8px;
|
||
color: #cf1322;
|
||
">
|
||
<svg style="width: 48px; height: 48px; margin-bottom: 12px;" fill="currentColor" viewBox="0 0 20 20">
|
||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
|
||
</svg>
|
||
<p style="margin: 0; font-weight: 600;">${escapeHtml(message)}</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
/**
|
||
* 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, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
/**
|
||
* Initialize all video blocks on page load
|
||
*/
|
||
function initVideoBlocks() {
|
||
const blocks = document.querySelectorAll('.video-block');
|
||
console.log(`Found ${blocks.length} video block(s) to hydrate`);
|
||
|
||
blocks.forEach(block => {
|
||
// Skip if already rendered (has video element)
|
||
if (block.querySelector('video')) {
|
||
console.log('Video block already rendered, skipping');
|
||
return;
|
||
}
|
||
|
||
renderVideoBlock(block);
|
||
});
|
||
}
|
||
|
||
// Initialize when DOM is ready
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', initVideoBlocks);
|
||
} else {
|
||
initVideoBlocks();
|
||
}
|
||
|
||
// Re-initialize on MkDocs navigation (for SPA-style navigation)
|
||
if (typeof window.document$ !== 'undefined') {
|
||
window.document$.subscribe(() => {
|
||
console.log('MkDocs navigation detected, re-initializing video blocks');
|
||
setTimeout(initVideoBlocks, 100);
|
||
});
|
||
}
|
||
})();
|