simplify video stuff
This commit is contained in:
		
							parent
							
								
									b291492e2d
								
							
						
					
					
						commit
						27b94edcfa
					
				@ -1,5 +1,5 @@
 | 
			
		||||
---
 | 
			
		||||
// src/components/Video.astro - SIMPLE wrapper component
 | 
			
		||||
// src/components/Video.astro - SIMPLE responsive video component
 | 
			
		||||
export interface Props {
 | 
			
		||||
  src: string;
 | 
			
		||||
  title?: string;
 | 
			
		||||
@ -8,6 +8,7 @@ export interface Props {
 | 
			
		||||
  muted?: boolean;
 | 
			
		||||
  loop?: boolean;
 | 
			
		||||
  aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
  preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const {
 | 
			
		||||
@ -17,17 +18,21 @@ const {
 | 
			
		||||
  autoplay = false,
 | 
			
		||||
  muted = false,
 | 
			
		||||
  loop = false,
 | 
			
		||||
  aspectRatio = '16:9'
 | 
			
		||||
  aspectRatio = '16:9',
 | 
			
		||||
  preload = 'metadata'
 | 
			
		||||
} = Astro.props;
 | 
			
		||||
 | 
			
		||||
const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
<div class={`video-container aspect-${aspectRatio}`}>
 | 
			
		||||
<div class={`video-container ${aspectClass}`}>
 | 
			
		||||
  <video 
 | 
			
		||||
    src={src}
 | 
			
		||||
    controls={controls}
 | 
			
		||||
    autoplay={autoplay}
 | 
			
		||||
    muted={muted}
 | 
			
		||||
    loop={loop}
 | 
			
		||||
    preload={preload}
 | 
			
		||||
    style="width: 100%; height: 100%;"
 | 
			
		||||
    data-video-title={title}
 | 
			
		||||
  >
 | 
			
		||||
 | 
			
		||||
@ -17,10 +17,8 @@ sections:
 | 
			
		||||
  advanced_topics: false
 | 
			
		||||
review_status: "published"
 | 
			
		||||
---
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Nextcloud Demo"></video>
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls title="Training Video"></video>
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/HdRwZXJ8NL6CT2q/download" controls></video>
 | 
			
		||||
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
 | 
			
		||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,8 @@ sections:
 | 
			
		||||
  advanced_topics: true
 | 
			
		||||
review_status: "published"
 | 
			
		||||
---
 | 
			
		||||
<video src="https://cloud.cc24.dev/s/ZmPK86M86fWyGQk" controls title="Training Video"></video>
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
> **⚠️ Hinweis**: Dies ist ein vorläufiger, KI-generierter Knowledgebase-Eintrag. Wir freuen uns über Verbesserungen und Ergänzungen durch die Community!
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -352,6 +352,37 @@ const { title, description = 'ForensicPathways - A comprehensive directory of di
 | 
			
		||||
    };
 | 
			
		||||
    initAIButton();
 | 
			
		||||
  });
 | 
			
		||||
    document.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
      const isFirefox = navigator.userAgent.toLowerCase().includes('firefox') || 
 | 
			
		||||
                        navigator.userAgent.toLowerCase().includes('librewolf');
 | 
			
		||||
      
 | 
			
		||||
      if (isFirefox) {
 | 
			
		||||
        console.log('[VIDEO] Firefox detected - setting up error recovery');
 | 
			
		||||
        
 | 
			
		||||
        document.querySelectorAll('video').forEach(video => {
 | 
			
		||||
          let errorCount = 0;
 | 
			
		||||
          
 | 
			
		||||
          video.addEventListener('error', () => {
 | 
			
		||||
            errorCount++;
 | 
			
		||||
            console.log(`[VIDEO] Error ${errorCount} in Firefox for: ${video.getAttribute('data-video-title')}`);
 | 
			
		||||
            
 | 
			
		||||
            // Only try once to avoid infinite loops
 | 
			
		||||
            if (errorCount === 1 && video.src.includes('/download')) {
 | 
			
		||||
              console.log('[VIDEO] Trying /preview URL for Firefox compatibility');
 | 
			
		||||
              video.src = video.src.replace('/download', '/preview');
 | 
			
		||||
              video.load();
 | 
			
		||||
            } else if (errorCount === 1) {
 | 
			
		||||
              console.log('[VIDEO] Video failed to load in Firefox');
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
          
 | 
			
		||||
          video.addEventListener('loadedmetadata', () => {
 | 
			
		||||
            const title = video.getAttribute('data-video-title') || 'Video';
 | 
			
		||||
            console.log(`[VIDEO] Successfully loaded: ${title}`);
 | 
			
		||||
          });
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
</script>
 | 
			
		||||
</head>
 | 
			
		||||
<body>
 | 
			
		||||
 | 
			
		||||
@ -1,142 +0,0 @@
 | 
			
		||||
// src/pages/api/video/cached/[...path].ts - Production video serving only
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { promises as fs } from 'fs';
 | 
			
		||||
import path from 'path';
 | 
			
		||||
 | 
			
		||||
export const GET: APIRoute = async ({ params, request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const videoPath = params.path;
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO SERVE] Request for cached video: ${videoPath}`);
 | 
			
		||||
    
 | 
			
		||||
    if (!videoPath || typeof videoPath !== 'string') {
 | 
			
		||||
      console.warn('[VIDEO SERVE] Invalid video path provided');
 | 
			
		||||
      return new Response('Video not found', { status: 404 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Security: Prevent path traversal
 | 
			
		||||
    const safePath = path.normalize(videoPath).replace(/^(\.\.[\/\\])+/, '');
 | 
			
		||||
    const cacheDir = process.env.VIDEO_CACHE_DIR || './cache/videos';
 | 
			
		||||
    const fullPath = path.join(cacheDir, safePath);
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO SERVE] Resolved cache path: ${fullPath}`);
 | 
			
		||||
    
 | 
			
		||||
    // Ensure the requested file is within the cache directory
 | 
			
		||||
    if (!fullPath.startsWith(path.resolve(cacheDir))) {
 | 
			
		||||
      console.error(`[VIDEO SERVE] Path traversal attempt blocked: ${fullPath}`);
 | 
			
		||||
      return new Response('Access denied', { status: 403 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const stat = await fs.stat(fullPath);
 | 
			
		||||
      
 | 
			
		||||
      if (!stat.isFile()) {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] Requested path is not a file: ${fullPath}`);
 | 
			
		||||
        return new Response('Video not found', { status: 404 });
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO SERVE] Serving cached video: ${safePath} (${Math.round(stat.size / 1024 / 1024)}MB)`);
 | 
			
		||||
      
 | 
			
		||||
      // Update access time for LRU tracking (for emergency cleanup)
 | 
			
		||||
      const now = new Date();
 | 
			
		||||
      await fs.utimes(fullPath, now, stat.mtime).catch((err) => {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] Failed to update access time for ${safePath}: ${err.message}`);
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      // Determine content type
 | 
			
		||||
      const ext = path.extname(fullPath).toLowerCase();
 | 
			
		||||
      const contentType = getVideoMimeType(ext);
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO SERVE] Content type: ${contentType}, File size: ${stat.size} bytes`);
 | 
			
		||||
      
 | 
			
		||||
      // Handle range requests for video streaming
 | 
			
		||||
      const range = request.headers.get('range');
 | 
			
		||||
      const fileSize = stat.size;
 | 
			
		||||
      
 | 
			
		||||
      if (range) {
 | 
			
		||||
        console.log(`[VIDEO SERVE] Range request: ${range}`);
 | 
			
		||||
        
 | 
			
		||||
        // Parse range header
 | 
			
		||||
        const rangeMatch = range.match(/bytes=(\d+)-(\d*)/);
 | 
			
		||||
        if (rangeMatch) {
 | 
			
		||||
          const start = parseInt(rangeMatch[1]);
 | 
			
		||||
          const end = rangeMatch[2] ? parseInt(rangeMatch[2]) : fileSize - 1;
 | 
			
		||||
          const chunkSize = end - start + 1;
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO SERVE] Range: ${start}-${end}, chunk size: ${chunkSize}`);
 | 
			
		||||
          
 | 
			
		||||
          if (start >= fileSize || end >= fileSize || start > end) {
 | 
			
		||||
            console.warn(`[VIDEO SERVE] Invalid range: ${start}-${end} for file size ${fileSize}`);
 | 
			
		||||
            return new Response('Range not satisfiable', { 
 | 
			
		||||
              status: 416,
 | 
			
		||||
              headers: {
 | 
			
		||||
                'Content-Range': `bytes */${fileSize}`
 | 
			
		||||
              }
 | 
			
		||||
            });
 | 
			
		||||
          }
 | 
			
		||||
          
 | 
			
		||||
          const fileStream = await fs.readFile(fullPath);
 | 
			
		||||
          const chunk = fileStream.slice(start, end + 1);
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO SERVE] Serving partial content: ${chunk.length} bytes`);
 | 
			
		||||
          
 | 
			
		||||
          return new Response(chunk, {
 | 
			
		||||
            status: 206,
 | 
			
		||||
            headers: {
 | 
			
		||||
              'Content-Type': contentType,
 | 
			
		||||
              'Content-Length': chunkSize.toString(),
 | 
			
		||||
              'Content-Range': `bytes ${start}-${end}/${fileSize}`,
 | 
			
		||||
              'Accept-Ranges': 'bytes',
 | 
			
		||||
              'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
 | 
			
		||||
              'Last-Modified': stat.mtime.toUTCString()
 | 
			
		||||
            }
 | 
			
		||||
          });
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Serve entire file
 | 
			
		||||
      console.log(`[VIDEO SERVE] Serving complete file: ${safePath}`);
 | 
			
		||||
      const fileBuffer = await fs.readFile(fullPath);
 | 
			
		||||
      
 | 
			
		||||
      return new Response(fileBuffer, {
 | 
			
		||||
        status: 200,
 | 
			
		||||
        headers: {
 | 
			
		||||
          'Content-Type': contentType,
 | 
			
		||||
          'Content-Length': fileSize.toString(),
 | 
			
		||||
          'Accept-Ranges': 'bytes',
 | 
			
		||||
          'Cache-Control': 'public, max-age=31536000', // Cache for 1 year
 | 
			
		||||
          'Last-Modified': stat.mtime.toUTCString()
 | 
			
		||||
        }
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      if (error.code === 'ENOENT') {
 | 
			
		||||
        console.warn(`[VIDEO SERVE] File not found: ${fullPath}`);
 | 
			
		||||
        return new Response('Video not found', { status: 404 });
 | 
			
		||||
      }
 | 
			
		||||
      console.error(`[VIDEO SERVE] File system error for ${fullPath}:`, error);
 | 
			
		||||
      throw error;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[VIDEO SERVE] Unexpected error serving cached video:', error);
 | 
			
		||||
    return new Response('Internal server error', { status: 500 });
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
function getVideoMimeType(extension: string): string {
 | 
			
		||||
  const mimeTypes: Record<string, string> = {
 | 
			
		||||
    '.mp4': 'video/mp4',
 | 
			
		||||
    '.webm': 'video/webm',
 | 
			
		||||
    '.ogg': 'video/ogg',
 | 
			
		||||
    '.mov': 'video/quicktime',
 | 
			
		||||
    '.avi': 'video/x-msvideo',
 | 
			
		||||
    '.m4v': 'video/x-m4v',
 | 
			
		||||
    '.3gp': 'video/3gpp'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  const mimeType = mimeTypes[extension] || 'application/octet-stream';
 | 
			
		||||
  console.log(`[VIDEO SERVE] MIME type for ${extension}: ${mimeType}`);
 | 
			
		||||
  
 | 
			
		||||
  return mimeType;
 | 
			
		||||
}
 | 
			
		||||
@ -1,43 +0,0 @@
 | 
			
		||||
// src/pages/api/video/process.ts
 | 
			
		||||
import type { APIRoute } from 'astro';
 | 
			
		||||
import { videoProcessor, type VideoMetadata } from '../../../utils/videoUtils.js';
 | 
			
		||||
import { apiResponse, apiError, apiServerError } from '../../../utils/api.js';
 | 
			
		||||
 | 
			
		||||
export const POST: APIRoute = async ({ request }) => {
 | 
			
		||||
  try {
 | 
			
		||||
    const body = await request.json().catch(() => null);
 | 
			
		||||
    
 | 
			
		||||
    if (!body) {
 | 
			
		||||
      return apiError.badRequest('Request body must be valid JSON');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const { url, metadata = {}, options = {} } = body;
 | 
			
		||||
    
 | 
			
		||||
    if (!url || typeof url !== 'string') {
 | 
			
		||||
      return apiError.badRequest('Video URL is required');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Validate URL
 | 
			
		||||
    try {
 | 
			
		||||
      new URL(url);
 | 
			
		||||
    } catch {
 | 
			
		||||
      return apiError.badRequest('Invalid video URL format');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO API] Processing video: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    const processedVideo = await videoProcessor.processVideoUrl(url, metadata as Partial<VideoMetadata>);
 | 
			
		||||
    const html = videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
    
 | 
			
		||||
    return apiResponse.success({
 | 
			
		||||
      processedVideo,
 | 
			
		||||
      html,
 | 
			
		||||
      cached: processedVideo.sources.some(s => s.cached),
 | 
			
		||||
      requiresAuth: processedVideo.requiresAuth
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
  } catch (error) {
 | 
			
		||||
    console.error('[VIDEO API] Processing error:', error);
 | 
			
		||||
    return apiServerError.internal(`Video processing failed: ${error.message}`);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -1,58 +1,95 @@
 | 
			
		||||
// src/utils/remarkVideoPlugin.ts - MINIMAL wrapper only
 | 
			
		||||
// src/utils/remarkVideoPlugin.ts - Simple, working approach
 | 
			
		||||
import { visit } from 'unist-util-visit';
 | 
			
		||||
import type { Plugin } from 'unified';
 | 
			
		||||
import type { Root } from 'hast';
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * MINIMAL plugin - just wraps <video> tags in responsive containers
 | 
			
		||||
 * Simple video plugin - just makes videos responsive and adds /download to bare Nextcloud URLs
 | 
			
		||||
 * No CORS complications, no crossorigin attributes
 | 
			
		||||
 */
 | 
			
		||||
export const remarkVideoPlugin: Plugin<[], Root> = () => {
 | 
			
		||||
  return (tree: Root) => {
 | 
			
		||||
    // Find HTML nodes containing <video> tags
 | 
			
		||||
    visit(tree, 'html', (node: any, index: number | undefined, parent: any) => {
 | 
			
		||||
      if (node.value && node.value.includes('<video') && typeof index === 'number') {
 | 
			
		||||
        
 | 
			
		||||
        // Extract video attributes
 | 
			
		||||
        const srcMatch = node.value.match(/src=["']([^"']+)["']/);
 | 
			
		||||
        const titleMatch = node.value.match(/title=["']([^"']+)["']/);
 | 
			
		||||
        
 | 
			
		||||
        if (srcMatch) {
 | 
			
		||||
          const src = srcMatch[1];
 | 
			
		||||
          const originalSrc = srcMatch[1];
 | 
			
		||||
          const title = titleMatch?.[1] || 'Video';
 | 
			
		||||
          
 | 
			
		||||
          // Check for existing attributes
 | 
			
		||||
          // Smart URL processing - add /download to bare Nextcloud URLs
 | 
			
		||||
          const finalSrc = processNextcloudUrl(originalSrc);
 | 
			
		||||
          
 | 
			
		||||
          // Check for existing attributes to preserve them
 | 
			
		||||
          const hasControls = node.value.includes('controls');
 | 
			
		||||
          const hasAutoplay = node.value.includes('autoplay');
 | 
			
		||||
          const hasMuted = node.value.includes('muted');
 | 
			
		||||
          const hasLoop = node.value.includes('loop');
 | 
			
		||||
          const hasPreload = node.value.match(/preload=["']([^"']+)["']/);
 | 
			
		||||
          
 | 
			
		||||
          // Create wrapped HTML
 | 
			
		||||
          const wrappedHTML = `
 | 
			
		||||
            <div class="video-container aspect-16:9">
 | 
			
		||||
          // Create simple, working video HTML - NO crossorigin attribute
 | 
			
		||||
          const enhancedHTML = `
 | 
			
		||||
            <div class="video-container aspect-16-9">
 | 
			
		||||
              <video 
 | 
			
		||||
                src="${escapeHtml(src)}"
 | 
			
		||||
                src="${escapeHtml(finalSrc)}"
 | 
			
		||||
                ${hasControls ? 'controls' : ''}
 | 
			
		||||
                ${hasAutoplay ? 'autoplay' : ''}
 | 
			
		||||
                ${hasMuted ? 'muted' : ''}
 | 
			
		||||
                ${hasLoop ? 'loop' : ''}
 | 
			
		||||
                ${hasPreload ? `preload="${hasPreload[1]}"` : 'preload="metadata"'}
 | 
			
		||||
                style="width: 100%; height: 100%;"
 | 
			
		||||
                data-video-title="${escapeHtml(title)}"
 | 
			
		||||
                data-original-src="${escapeHtml(originalSrc)}"
 | 
			
		||||
              >
 | 
			
		||||
                <p>Your browser does not support the video element.</p>
 | 
			
		||||
              </video>
 | 
			
		||||
              ${title !== 'Video' ? `
 | 
			
		||||
                <div class="video-metadata">
 | 
			
		||||
                  <div class="video-title">${escapeHtml(title)}</div>
 | 
			
		||||
                </div>
 | 
			
		||||
              ` : ''}
 | 
			
		||||
            </div>
 | 
			
		||||
          `.trim();
 | 
			
		||||
          
 | 
			
		||||
          // Replace the node
 | 
			
		||||
          parent.children[index] = { type: 'html', value: wrappedHTML };
 | 
			
		||||
          parent.children[index] = { type: 'html', value: enhancedHTML };
 | 
			
		||||
          
 | 
			
		||||
          console.log(`[VIDEO] Processed: ${title}`);
 | 
			
		||||
          console.log(`[VIDEO] Final URL: ${finalSrc}`);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Simple URL processing - just add /download to bare Nextcloud URLs if needed
 | 
			
		||||
 */
 | 
			
		||||
function processNextcloudUrl(originalUrl: string): string {
 | 
			
		||||
  // If it's a bare Nextcloud share URL, add /download
 | 
			
		||||
  if (isNextcloudShareUrl(originalUrl) && !originalUrl.includes('/download')) {
 | 
			
		||||
    const downloadUrl = `${originalUrl}/download`;
 | 
			
		||||
    console.log(`[VIDEO] Auto-added /download: ${originalUrl} → ${downloadUrl}`);
 | 
			
		||||
    return downloadUrl;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  // Otherwise, use the URL as-is
 | 
			
		||||
  return originalUrl;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Check if URL is a Nextcloud share URL (bare or with /download)
 | 
			
		||||
 * Format: https://cloud.cc24.dev/s/TOKEN
 | 
			
		||||
 */
 | 
			
		||||
function isNextcloudShareUrl(url: string): boolean {
 | 
			
		||||
  const pattern = /\/s\/[a-zA-Z0-9]+/;
 | 
			
		||||
  return pattern.test(url) && (url.includes('nextcloud') || url.includes('cloud.'));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escapeHtml(unsafe: string): string {
 | 
			
		||||
  if (typeof unsafe !== 'string') return '';
 | 
			
		||||
  
 | 
			
		||||
 | 
			
		||||
@ -1,385 +1,20 @@
 | 
			
		||||
// src/utils/videoUtils.ts - NEXTCLOUD ONLY
 | 
			
		||||
import { NextcloudUploader } from './nextcloud.js';
 | 
			
		||||
// src/utils/videoUtils.ts - SIMPLIFIED - Basic utilities only
 | 
			
		||||
import 'dotenv/config';
 | 
			
		||||
 | 
			
		||||
export interface VideoSource {
 | 
			
		||||
  type: 'nextcloud' | 'cdn' | 'local';
 | 
			
		||||
  url: string;
 | 
			
		||||
  originalUrl?: string;
 | 
			
		||||
  cached?: boolean;
 | 
			
		||||
}
 | 
			
		||||
/**
 | 
			
		||||
 * Simple video utilities for basic video support
 | 
			
		||||
 * No caching, no complex processing, no authentication
 | 
			
		||||
 */
 | 
			
		||||
 | 
			
		||||
export interface VideoMetadata {
 | 
			
		||||
export interface SimpleVideoMetadata {
 | 
			
		||||
  title?: string;
 | 
			
		||||
  duration?: number;
 | 
			
		||||
  format?: string;
 | 
			
		||||
  size?: number;
 | 
			
		||||
  width?: number;
 | 
			
		||||
  height?: number;
 | 
			
		||||
  poster?: string;
 | 
			
		||||
  description?: string;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export interface ProcessedVideo {
 | 
			
		||||
  sources: VideoSource[];
 | 
			
		||||
  metadata: VideoMetadata;
 | 
			
		||||
  fallbackText: string;
 | 
			
		||||
  requiresAuth: boolean;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface VideoConfig {
 | 
			
		||||
  enableCaching: boolean;
 | 
			
		||||
  cacheDirectory: string;
 | 
			
		||||
  maxCacheSize: number;
 | 
			
		||||
  supportedFormats: string[];
 | 
			
		||||
  maxFileSize: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export class VideoProcessor {
 | 
			
		||||
  private config: VideoConfig;
 | 
			
		||||
  private nextcloudUploader: NextcloudUploader;
 | 
			
		||||
  
 | 
			
		||||
  constructor() {
 | 
			
		||||
    this.config = {
 | 
			
		||||
      enableCaching: process.env.VIDEO_CACHE_ENABLED === 'true',
 | 
			
		||||
      cacheDirectory: process.env.VIDEO_CACHE_DIR || './cache/videos',
 | 
			
		||||
      maxCacheSize: parseInt(process.env.VIDEO_CACHE_MAX_SIZE || '2000'),
 | 
			
		||||
      supportedFormats: ['mp4', 'webm', 'ogg', 'mov', 'avi'],
 | 
			
		||||
      maxFileSize: parseInt(process.env.VIDEO_MAX_SIZE || '200')
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    this.nextcloudUploader = new NextcloudUploader();
 | 
			
		||||
    console.log('[VIDEO] Nextcloud-only video processor initialized');
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  async processVideoUrl(url: string, metadata: Partial<VideoMetadata> = {}): Promise<ProcessedVideo> {
 | 
			
		||||
    console.log(`[VIDEO] Processing: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const videoSource = this.identifyVideoSource(url);
 | 
			
		||||
      console.log(`[VIDEO] Source type: ${videoSource.type}`);
 | 
			
		||||
      
 | 
			
		||||
      const sources: VideoSource[] = [];
 | 
			
		||||
      
 | 
			
		||||
      switch (videoSource.type) {
 | 
			
		||||
        case 'nextcloud':
 | 
			
		||||
          sources.push(...await this.processNextcloudVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'cdn':
 | 
			
		||||
          sources.push(await this.processCdnVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
        case 'local':
 | 
			
		||||
          sources.push(await this.processLocalVideo(url));
 | 
			
		||||
          break;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const enhancedMetadata = await this.enhanceMetadata(sources[0], metadata);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        sources,
 | 
			
		||||
        metadata: enhancedMetadata,
 | 
			
		||||
        fallbackText: this.generateFallbackText(enhancedMetadata),
 | 
			
		||||
        requiresAuth: this.requiresAuthentication(videoSource.type)
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[VIDEO] Processing failed: ${error.message}`);
 | 
			
		||||
      return {
 | 
			
		||||
        sources: [],
 | 
			
		||||
        metadata: { ...metadata, title: metadata.title || 'Video unavailable' },
 | 
			
		||||
        fallbackText: `Video could not be loaded: ${error.message}`,
 | 
			
		||||
        requiresAuth: false
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private identifyVideoSource(url: string): { type: VideoSource['type']; url: string } {
 | 
			
		||||
    console.log(`[VIDEO] Identifying source for: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    // Check for Nextcloud patterns
 | 
			
		||||
    if (url.includes('/s/') || url.includes('nextcloud') || url.includes('owncloud')) {
 | 
			
		||||
      console.log(`[VIDEO] Detected Nextcloud URL`);
 | 
			
		||||
      return { type: 'nextcloud', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Local files
 | 
			
		||||
    if (url.startsWith('/') && !url.startsWith('http')) {
 | 
			
		||||
      console.log(`[VIDEO] Detected local file`);
 | 
			
		||||
      return { type: 'local', url };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Everything else is a CDN
 | 
			
		||||
    console.log(`[VIDEO] Detected CDN URL`);
 | 
			
		||||
    return { type: 'cdn', url };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  private async processNextcloudVideo(url: string): Promise<VideoSource[]> {
 | 
			
		||||
    console.log(`[VIDEO] Processing Nextcloud: ${url}`);
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const shareMatch = url.match(/\/s\/([^\/\?]+)/);
 | 
			
		||||
      if (!shareMatch) {
 | 
			
		||||
        throw new Error('Invalid Nextcloud share URL format');
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const shareToken = shareMatch[1];
 | 
			
		||||
      const directUrl = await this.getNextcloudDirectUrl(url, shareToken);
 | 
			
		||||
      const sources: VideoSource[] = [];
 | 
			
		||||
      
 | 
			
		||||
      // Try caching if enabled
 | 
			
		||||
      if (this.config.enableCaching && directUrl) {
 | 
			
		||||
        const cachedSource = await this.cacheVideo(directUrl, shareToken);
 | 
			
		||||
        if (cachedSource) {
 | 
			
		||||
          sources.push(cachedSource);
 | 
			
		||||
        }
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      // Always include direct source as fallback
 | 
			
		||||
      sources.push({
 | 
			
		||||
        type: 'nextcloud',
 | 
			
		||||
        url: directUrl || url,
 | 
			
		||||
        originalUrl: url,
 | 
			
		||||
        cached: false
 | 
			
		||||
      });
 | 
			
		||||
      
 | 
			
		||||
      return sources;
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[VIDEO] Nextcloud processing failed:', error);
 | 
			
		||||
      return [{
 | 
			
		||||
        type: 'nextcloud',
 | 
			
		||||
        url: url,
 | 
			
		||||
        cached: false
 | 
			
		||||
      }];
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async getNextcloudDirectUrl(shareUrl: string, shareToken: string): Promise<string | null> {
 | 
			
		||||
    try {
 | 
			
		||||
      const baseUrl = shareUrl.split('/s/')[0];
 | 
			
		||||
      const directUrl = `${baseUrl}/s/${shareToken}/download`;
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(directUrl, { method: 'HEAD' });
 | 
			
		||||
      
 | 
			
		||||
      if (response.ok) {
 | 
			
		||||
        return directUrl;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return null;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.warn('[VIDEO] Direct URL extraction failed:', error);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async processCdnVideo(url: string): Promise<VideoSource> {
 | 
			
		||||
    console.log(`[VIDEO] Processing CDN: ${url}`);
 | 
			
		||||
    return {
 | 
			
		||||
      type: 'cdn',
 | 
			
		||||
      url,
 | 
			
		||||
      cached: false
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async processLocalVideo(url: string): Promise<VideoSource> {
 | 
			
		||||
    return {
 | 
			
		||||
      type: 'local',
 | 
			
		||||
      url,
 | 
			
		||||
      cached: true
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async cacheVideo(sourceUrl: string, identifier: string): Promise<VideoSource | null> {
 | 
			
		||||
    if (!this.config.enableCaching) {
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
      const fs = await import('fs/promises');
 | 
			
		||||
      const path = await import('path');
 | 
			
		||||
      const crypto = await import('crypto');
 | 
			
		||||
      
 | 
			
		||||
      const urlHash = crypto.createHash('sha256').update(sourceUrl).digest('hex').substring(0, 16);
 | 
			
		||||
      const extension = path.extname(new URL(sourceUrl).pathname) || '.mp4';
 | 
			
		||||
      const cacheFilename = `${identifier}_${urlHash}${extension}`;
 | 
			
		||||
      const cachePath = path.join(this.config.cacheDirectory, cacheFilename);
 | 
			
		||||
      
 | 
			
		||||
      // Check if already cached
 | 
			
		||||
      try {
 | 
			
		||||
        const stat = await fs.stat(cachePath);
 | 
			
		||||
        console.log(`[VIDEO] Using cached: ${cacheFilename}`);
 | 
			
		||||
        
 | 
			
		||||
        return {
 | 
			
		||||
          type: 'local',
 | 
			
		||||
          url: `/api/video/cached/${cacheFilename}`,
 | 
			
		||||
          originalUrl: sourceUrl,
 | 
			
		||||
          cached: true
 | 
			
		||||
        };
 | 
			
		||||
      } catch {
 | 
			
		||||
        // File doesn't exist, proceed with download
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      await fs.mkdir(this.config.cacheDirectory, { recursive: true });
 | 
			
		||||
      
 | 
			
		||||
      const response = await fetch(sourceUrl);
 | 
			
		||||
      
 | 
			
		||||
      if (!response.ok) {
 | 
			
		||||
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const contentLength = parseInt(response.headers.get('content-length') || '0');
 | 
			
		||||
      
 | 
			
		||||
      if (contentLength > this.config.maxFileSize * 1024 * 1024) {
 | 
			
		||||
        console.warn(`[VIDEO] File too large for caching: ${Math.round(contentLength / 1024 / 1024)}MB`);
 | 
			
		||||
        return null;
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      const buffer = await response.arrayBuffer();
 | 
			
		||||
      await fs.writeFile(cachePath, new Uint8Array(buffer));
 | 
			
		||||
      
 | 
			
		||||
      console.log(`[VIDEO] Cached: ${cacheFilename}`);
 | 
			
		||||
      
 | 
			
		||||
      return {
 | 
			
		||||
        type: 'local',
 | 
			
		||||
        url: `/api/video/cached/${cacheFilename}`,
 | 
			
		||||
        originalUrl: sourceUrl,
 | 
			
		||||
        cached: true
 | 
			
		||||
      };
 | 
			
		||||
      
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error(`[VIDEO] Caching failed: ${error.message}`);
 | 
			
		||||
      return null;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private async enhanceMetadata(source: VideoSource, providedMetadata: Partial<VideoMetadata>): Promise<VideoMetadata> {
 | 
			
		||||
    const metadata: VideoMetadata = { ...providedMetadata };
 | 
			
		||||
    
 | 
			
		||||
    if (source.type === 'local' && source.cached) {
 | 
			
		||||
      try {
 | 
			
		||||
        const path = await import('path');
 | 
			
		||||
        const ext = path.extname(source.url).toLowerCase().replace('.', '');
 | 
			
		||||
        
 | 
			
		||||
        if (!metadata.format) {
 | 
			
		||||
          metadata.format = ext;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (!metadata.title) {
 | 
			
		||||
          metadata.title = path.basename(source.url, path.extname(source.url));
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.warn('[VIDEO] Metadata extraction failed:', error);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (!metadata.title) {
 | 
			
		||||
      metadata.title = 'Embedded Video';
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return metadata;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private generateFallbackText(metadata: VideoMetadata): string {
 | 
			
		||||
    let fallback = `[Video: ${metadata.title}]`;
 | 
			
		||||
    
 | 
			
		||||
    if (metadata.description) {
 | 
			
		||||
      fallback += ` - ${metadata.description}`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    if (metadata.duration) {
 | 
			
		||||
      fallback += ` (${Math.floor(metadata.duration / 60)}:${(metadata.duration % 60).toString().padStart(2, '0')})`;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return fallback;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private requiresAuthentication(sourceType: VideoSource['type']): boolean {
 | 
			
		||||
    return sourceType === 'nextcloud';
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  generateVideoHTML(processedVideo: ProcessedVideo, options: {
 | 
			
		||||
    controls?: boolean;
 | 
			
		||||
    autoplay?: boolean;
 | 
			
		||||
    muted?: boolean;
 | 
			
		||||
    loop?: boolean;
 | 
			
		||||
    preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
    width?: string;
 | 
			
		||||
    height?: string;
 | 
			
		||||
    aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
    showMetadata?: boolean;
 | 
			
		||||
  } = {}): string {
 | 
			
		||||
    
 | 
			
		||||
    const {
 | 
			
		||||
      controls = true,
 | 
			
		||||
      autoplay = false,
 | 
			
		||||
      muted = false,
 | 
			
		||||
      loop = false,
 | 
			
		||||
      preload = 'metadata',
 | 
			
		||||
      aspectRatio = '16:9',
 | 
			
		||||
      showMetadata = true
 | 
			
		||||
    } = options;
 | 
			
		||||
    
 | 
			
		||||
    if (processedVideo.sources.length === 0) {
 | 
			
		||||
      return `
 | 
			
		||||
        <div class="video-container aspect-${aspectRatio}">
 | 
			
		||||
          <div class="video-error">
 | 
			
		||||
            <div class="error-icon">⚠️</div>
 | 
			
		||||
            <div>${processedVideo.fallbackText}</div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      `;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const primarySource = processedVideo.sources[0];
 | 
			
		||||
    const { metadata } = processedVideo;
 | 
			
		||||
 | 
			
		||||
    // Simple video attributes - no crossorigin complications
 | 
			
		||||
    const videoAttributes = [
 | 
			
		||||
      controls ? 'controls' : '',
 | 
			
		||||
      autoplay ? 'autoplay' : '',
 | 
			
		||||
      muted ? 'muted' : '',
 | 
			
		||||
      loop ? 'loop' : '',
 | 
			
		||||
      `preload="${preload}"`,
 | 
			
		||||
      metadata.poster ? `poster="${this.escapeHtml(metadata.poster)}"` : '',
 | 
			
		||||
      `data-video-title="${this.escapeHtml(metadata.title || 'Embedded Video')}"`
 | 
			
		||||
    ].filter(Boolean).join(' ');
 | 
			
		||||
    
 | 
			
		||||
    const sourceTags = processedVideo.sources
 | 
			
		||||
      .map(source => {
 | 
			
		||||
        const mimeType = this.getMimeType(source.url);
 | 
			
		||||
        return `<source src="${this.escapeHtml(source.url)}" type="${mimeType}">`;
 | 
			
		||||
      })
 | 
			
		||||
      .join('\n      ');
 | 
			
		||||
    
 | 
			
		||||
    const metadataHTML = showMetadata && (metadata.title || metadata.duration || metadata.format) ? `
 | 
			
		||||
      <div class="video-metadata">
 | 
			
		||||
        ${metadata.title ? `<div class="video-title">${this.escapeHtml(metadata.title)}</div>` : ''}
 | 
			
		||||
        <div class="video-info">
 | 
			
		||||
          ${metadata.duration ? `<div class="video-duration">⏱️ ${this.formatDuration(metadata.duration)}</div>` : ''}
 | 
			
		||||
          ${metadata.format ? `<div class="video-format">🎥 ${metadata.format.toUpperCase()}</div>` : ''}
 | 
			
		||||
          ${metadata.size ? `<div class="video-size">💾 ${this.formatFileSize(metadata.size)}</div>` : ''}
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    ` : '';
 | 
			
		||||
    
 | 
			
		||||
    const html = `
 | 
			
		||||
      <div class="video-container aspect-${aspectRatio}">
 | 
			
		||||
        <video ${videoAttributes}>
 | 
			
		||||
          ${sourceTags}
 | 
			
		||||
          <p>Your browser does not support the video element. ${processedVideo.fallbackText}</p>
 | 
			
		||||
        </video>
 | 
			
		||||
        ${metadataHTML}
 | 
			
		||||
      </div>
 | 
			
		||||
    `.trim();
 | 
			
		||||
    
 | 
			
		||||
    console.log(`[VIDEO] Generated HTML for ${processedVideo.sources[0]?.url}:`);
 | 
			
		||||
    console.log(html.substring(0, 200) + '...');
 | 
			
		||||
    
 | 
			
		||||
    return html;
 | 
			
		||||
  }
 | 
			
		||||
  
 | 
			
		||||
  private getMimeType(url: string): string {
 | 
			
		||||
/**
 | 
			
		||||
 * Get video MIME type from file extension
 | 
			
		||||
 */
 | 
			
		||||
export function getVideoMimeType(url: string): string {
 | 
			
		||||
  let extension: string | undefined;
 | 
			
		||||
  try {
 | 
			
		||||
    const pathname = new URL(url).pathname;
 | 
			
		||||
@ -394,13 +29,18 @@ export class VideoProcessor {
 | 
			
		||||
    ogg: 'video/ogg',
 | 
			
		||||
    mov: 'video/quicktime',
 | 
			
		||||
    avi: 'video/x-msvideo',
 | 
			
		||||
      m4v: 'video/m4v'
 | 
			
		||||
    m4v: 'video/m4v',
 | 
			
		||||
    mkv: 'video/x-matroska',
 | 
			
		||||
    flv: 'video/x-flv'
 | 
			
		||||
  };
 | 
			
		||||
  
 | 
			
		||||
  return (extension && mimeTypes[extension]) || 'video/mp4';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  private formatDuration(seconds: number): string {
 | 
			
		||||
/**
 | 
			
		||||
 * Format duration in MM:SS or HH:MM:SS format
 | 
			
		||||
 */
 | 
			
		||||
export function formatDuration(seconds: number): string {
 | 
			
		||||
  const hours = Math.floor(seconds / 3600);
 | 
			
		||||
  const minutes = Math.floor((seconds % 3600) / 60);
 | 
			
		||||
  const remainingSeconds = Math.floor(seconds % 60);
 | 
			
		||||
@ -412,14 +52,20 @@ export class VideoProcessor {
 | 
			
		||||
  return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  private formatFileSize(bytes: number): string {
 | 
			
		||||
/**
 | 
			
		||||
 * Format file size in human readable format
 | 
			
		||||
 */
 | 
			
		||||
export function formatFileSize(bytes: number): string {
 | 
			
		||||
  if (bytes < 1024) return `${bytes} B`;
 | 
			
		||||
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
 | 
			
		||||
  if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
 | 
			
		||||
  return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
  private escapeHtml(unsafe: string): string {
 | 
			
		||||
/**
 | 
			
		||||
 * Escape HTML for safe output
 | 
			
		||||
 */
 | 
			
		||||
export function escapeHtml(unsafe: string): string {
 | 
			
		||||
  if (typeof unsafe !== 'string') return '';
 | 
			
		||||
  
 | 
			
		||||
  return unsafe
 | 
			
		||||
@ -429,13 +75,14 @@ export class VideoProcessor {
 | 
			
		||||
    .replace(/"/g, """)
 | 
			
		||||
    .replace(/'/g, "'");
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
export const videoProcessor = new VideoProcessor();
 | 
			
		||||
 | 
			
		||||
// Utility functions for markdown integration
 | 
			
		||||
export async function processVideoEmbed(url: string, metadata?: Partial<VideoMetadata>, options?: {
 | 
			
		||||
/**
 | 
			
		||||
 * Generate basic responsive video HTML
 | 
			
		||||
 */
 | 
			
		||||
export function generateVideoHTML(
 | 
			
		||||
  src: string, 
 | 
			
		||||
  options: {
 | 
			
		||||
    title?: string;
 | 
			
		||||
    controls?: boolean;
 | 
			
		||||
    autoplay?: boolean;
 | 
			
		||||
    muted?: boolean;
 | 
			
		||||
@ -443,7 +90,45 @@ export async function processVideoEmbed(url: string, metadata?: Partial<VideoMet
 | 
			
		||||
    preload?: 'none' | 'metadata' | 'auto';
 | 
			
		||||
    aspectRatio?: '16:9' | '4:3' | '1:1';
 | 
			
		||||
    showMetadata?: boolean;
 | 
			
		||||
}): Promise<string> {
 | 
			
		||||
  const processedVideo = await videoProcessor.processVideoUrl(url, metadata);
 | 
			
		||||
  return videoProcessor.generateVideoHTML(processedVideo, options);
 | 
			
		||||
  } = {}
 | 
			
		||||
): string {
 | 
			
		||||
  const {
 | 
			
		||||
    title = 'Video',
 | 
			
		||||
    controls = true,
 | 
			
		||||
    autoplay = false,
 | 
			
		||||
    muted = false,
 | 
			
		||||
    loop = false,
 | 
			
		||||
    preload = 'metadata',
 | 
			
		||||
    aspectRatio = '16:9',
 | 
			
		||||
    showMetadata = true
 | 
			
		||||
  } = options;
 | 
			
		||||
  
 | 
			
		||||
  const aspectClass = `aspect-${aspectRatio.replace(':', '-')}`;
 | 
			
		||||
  const videoAttributes = [
 | 
			
		||||
    controls ? 'controls' : '',
 | 
			
		||||
    autoplay ? 'autoplay' : '',
 | 
			
		||||
    muted ? 'muted' : '',
 | 
			
		||||
    loop ? 'loop' : '',
 | 
			
		||||
    `preload="${preload}"`
 | 
			
		||||
  ].filter(Boolean).join(' ');
 | 
			
		||||
  
 | 
			
		||||
  const metadataHTML = showMetadata && title !== 'Video' ? `
 | 
			
		||||
    <div class="video-metadata">
 | 
			
		||||
      <div class="video-title">${escapeHtml(title)}</div>
 | 
			
		||||
    </div>
 | 
			
		||||
  ` : '';
 | 
			
		||||
  
 | 
			
		||||
  return `
 | 
			
		||||
    <div class="video-container ${aspectClass}">
 | 
			
		||||
      <video 
 | 
			
		||||
        src="${escapeHtml(src)}"
 | 
			
		||||
        ${videoAttributes}
 | 
			
		||||
        style="width: 100%; height: 100%;"
 | 
			
		||||
        data-video-title="${escapeHtml(title)}"
 | 
			
		||||
      >
 | 
			
		||||
        <p>Your browser does not support the video element.</p>
 | 
			
		||||
      </video>
 | 
			
		||||
      ${metadataHTML}
 | 
			
		||||
    </div>
 | 
			
		||||
  `.trim();
 | 
			
		||||
}
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user