// Shared resources const THUMBNAIL_CACHE = new Map(); const THUMBNAIL_REGISTRY = new Map(); const VIDEO_SERVICES = new Map(); // Common constants const DEFAULT_ALLOW = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; fullscreen; web-share"; const DEFAULT_SANDBOX = "allow-scripts allow-same-origin allow-popups allow-forms allow-presentation"; // Efficient image checking with modern fetch API async function checkImage(url) { if (THUMBNAIL_CACHE.has(url)) return THUMBNAIL_CACHE.get(url); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2000); try { const response = await fetch(url, { method: 'HEAD', signal: controller.signal }); clearTimeout(timeoutId); const valid = response.ok; THUMBNAIL_CACHE.set(url, valid); return valid; } catch { clearTimeout(timeoutId); THUMBNAIL_CACHE.set(url, false); return false; } } // Helper for parsing URLs safely function parseUrl(url) { try { return new URL(url); } catch { return null; } } /** * Service Provider base class - each video service extends this */ class VideoServiceProvider { constructor() { this.name = 'generic'; } canHandle(url) { return false; } getVideoId(url) { return null; } getEmbedUrl(videoId, params, element) { return ''; } getThumbnailUrls(videoId, quality, element) { const customThumbnail = element.getAttribute("thumbnail"); return customThumbnail ? [customThumbnail] : []; } parseParams() { return {}; } getIframeAttributes(element) { return { frameborder: element.getAttribute("frameborder") || "0", allow: element.getAttribute("allow") || DEFAULT_ALLOW, sandbox: element.getAttribute("sandbox") || DEFAULT_SANDBOX }; } getDefaults() { return { autoload: false }; } } /** * YouTube service provider */ class YouTubeProvider extends VideoServiceProvider { constructor() { super(); this.name = 'youtube'; this.THUMBNAIL_QUALITIES = { maxres: 'maxresdefault.jpg', sd: 'sddefault.jpg', hq: 'hqdefault.jpg', mq: 'mqdefault.jpg', default: 'default.jpg' }; this.URL_PATTERNS = [ /youtube\.com\/embed\/([a-zA-Z0-9_-]{11})/, /youtube\.com\/watch\?v=([a-zA-Z0-9_-]{11})/, /youtu\.be\/([a-zA-Z0-9_-]{11})/ ]; } canHandle(url) { return url && /youtube\.com|youtu\.be/.test(url); } getVideoId(url) { if (!url) return null; const parsedUrl = parseUrl(url); if (parsedUrl) { // Path-based ID extraction (/embed/ID or youtu.be/ID) if (parsedUrl.pathname.startsWith("/embed/") || parsedUrl.hostname === "youtu.be") { const parts = parsedUrl.pathname.split("/"); return parts[parts.length > 2 ? 2 : 1]; } // Query-based ID extraction (?v=ID) const videoId = parsedUrl.searchParams.get("v"); if (videoId) return videoId; } // Fallback to regex matching for (const pattern of this.URL_PATTERNS) { const match = url.match(pattern); if (match?.[1]) return match[1]; } return null; } getEmbedUrl(videoId, params = {}, element) { // Determine domain based on cookie preference const useNoCookie = element.getAttribute("no-cookie") !== "false"; const domain = useNoCookie ? "youtube-nocookie.com" : "youtube.com"; // Build URL with parameters let url = `https://www.${domain}/embed/${videoId}?autoplay=1`; // Add parameters for (const [key, value] of Object.entries(params)) { if (key !== 'autoplay' && key && value) { url += `&${key}=${encodeURIComponent(value)}`; } } return url; } getThumbnailUrls(videoId, quality, element) { // Check for custom thumbnail first const customThumbnail = element.getAttribute("thumbnail"); if (customThumbnail) return [customThumbnail]; const baseUrl = `https://img.youtube.com/vi/${videoId}`; const urls = []; // Choose quality based on device and user preference if (quality && this.THUMBNAIL_QUALITIES[quality]) { urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES[quality]}`); } else if (window.matchMedia("(max-width: 767px)").matches) { urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`); } else { urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.maxres}`); } // Only add fallbacks if they're different from what we already have if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`)) { urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.hq}`); } if (!urls.includes(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`)) { urls.push(`${baseUrl}/${this.THUMBNAIL_QUALITIES.default}`); } return urls; } parseParams(url) { const params = {}; const parsedUrl = parseUrl(url); if (!parsedUrl) return params; // Extract parameters from URL for (const [key, value] of parsedUrl.searchParams.entries()) { params[key] = value; } // Handle YouTube-specific parameters if (params.t || params.start) params.start = params.t || params.start; if (params.list) params.playlist = params.list; return params; } } /** * Bitchute service provider */ class BitchuteProvider extends VideoServiceProvider { constructor() { super(); this.name = 'bitchute'; this.URL_PATTERNS = [ /bitchute\.com\/video\/([a-zA-Z0-9_-]+)/, /bitchute\.com\/embed\/([a-zA-Z0-9_-]+)/ ]; } canHandle(url) { return url && /bitchute\.com/.test(url); } getVideoId(url) { if (!url) return null; const parsedUrl = parseUrl(url); if (parsedUrl) { // Extract from path segments const segments = parsedUrl.pathname.split('/').filter(Boolean); for (let i = 0; i < segments.length - 1; i++) { if ((segments[i] === "embed" || segments[i] === "video") && i + 1 < segments.length) { return segments[i + 1]; } } } // Fallback to regex matching for (const pattern of this.URL_PATTERNS) { const match = url.match(pattern); if (match?.[1]) return match[1]; } return null; } getEmbedUrl(videoId) { return `https://www.bitchute.com/embed/${videoId}/`; } // Use parent class implementations for other methods getDefaults() { return { autoload: true }; } } // Register service providers VIDEO_SERVICES.set('youtube', new YouTubeProvider()); VIDEO_SERVICES.set('bitchute', new BitchuteProvider()); class LazyVideo extends HTMLElement { // Observable attributes static get observedAttributes() { return [ "src", "title", "width", "height", "thumbnail-quality", "no-cookie", "autoload", "frameborder", "allow", "loading", "hide-title", "thumbnail", "service", "align", "container-fit" ]; } // CSS styles definition static get styles() { return ` :host { --lv-aspect-ratio: 16 / 9; display: var(--lv-display, block); position: var(--lv-position, relative); width: var(--lv-width, 100%); max-width: var(--lv-max-width, 560px); aspect-ratio: var(--lv-aspect-ratio); background: var(--lv-background, #000); overflow: var(--lv-overflow, hidden); border-radius: var(--lv-border-radius, 0); margin: var(--lv-margin, 0 auto); } :host([container-fit]) { max-width: 100% !important; max-height: auto !important; width: 100%; margin: 0; } /* Alignment control through attribute */ :host([align="left"]) { margin: var(--lv-margin-left, 0); } :host([align="right"]) { margin: var(--lv-margin-right, 0 0 0 auto); } :host([align="center"]) { margin: var(--lv-margin-center, 0 auto); } /* Alignment classes for CSS variable-based alignment */ :host(.lv-align-left) { margin: var(--lv-margin-left, 0); } :host(.lv-align-right) { margin: var(--lv-margin-right, 0 0 0 auto); } :host(.lv-align-center) { margin: var(--lv-margin-center, 0 auto); } :host([hide-title]), :host(:where(:not([hide-title]))) { --lv-show-title: var(--lv-show-title, 1); } :host([hide-title]) [part="title-bar"] { display: none; } :host([style*="height"]) { aspect-ratio: auto; } [part="placeholder"], [part="iframe"] { position: absolute; top: 0; left: 0; width: 100%; height: 100%; border: none; } [part="placeholder"] { cursor: pointer; background: var(--lv-placeholder-bg, #000); } [part="placeholder"]:focus { outline: var(--lv-focus-outline, 2px solid #4285F4); outline-offset: var(--lv-focus-outline-offset, 2px); } [part="thumbnail"] { width: 100%; height: 100%; object-fit: var(--lv-thumbnail-object-fit, cover); opacity: var(--lv-thumbnail-opacity, 0.85); } [part="placeholder"]:hover [part="thumbnail"], [part="placeholder"]:focus [part="thumbnail"] { opacity: var(--lv-thumbnail-hover-opacity, 1); } [part="title-bar"] { position: absolute; top: 0; left: 0; width: 100%; padding: var(--lv-title-padding, 10px 12px); background: var(--lv-title-bg, rgba(0, 0, 0, 0.75)); color: var(--lv-title-color, white); font-family: var(--lv-title-font-family, Roboto, Arial, sans-serif); font-size: var(--lv-title-font-size, 18px); font-weight: var(--lv-title-font-weight, 500); line-height: var(--lv-title-line-height, 1.2); text-overflow: ellipsis; white-space: nowrap; overflow: hidden; z-index: 2; box-sizing: border-box; display: var(--lv-show-title, block); } [part="play-button"] { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: var(--lv-play-button-width, 68px); height: var(--lv-play-button-height, 48px); background: var(--lv-play-button-bg, rgba(33, 33, 33, 0.8)); border-radius: var(--lv-play-button-radius, 8px); } [part="play-button"]::after { content: ''; position: absolute; top: 50%; left: 55%; transform: translate(-50%, -50%); border-style: solid; border-width: var(--lv-play-button-arrow-size, 12px 0 12px 20px); border-color: transparent transparent transparent var(--lv-play-button-color, rgba(255, 255, 255, 0.9)); } [part="placeholder"]:hover [part="play-button"] { background: var(--lv-play-button-bg-hover, rgba(230, 33, 23, 1)); } [part="timestamp"] { position: absolute; right: var(--lv-timestamp-right, 10px); bottom: var(--lv-timestamp-bottom, 10px); background: var(--lv-timestamp-bg, rgba(0, 0, 0, 0.7)); color: var(--lv-timestamp-color, white); padding: var(--lv-timestamp-padding, 2px 6px); border-radius: var(--lv-timestamp-radius, 3px); font-size: var(--lv-timestamp-font-size, 12px); font-family: var(--lv-timestamp-font-family, system-ui, sans-serif); } [part="iframe"] { opacity: 0; animation: fadeIn 0.3s ease forwards; } @keyframes fadeIn { to { opacity: 1; } } [part="loading"], [part="fallback-thumbnail"] { display: flex; align-items: center; justify-content: center; position: absolute; width: 100%; height: 100%; } [part="loading"] { background: var(--lv-loading-bg, rgba(0,0,0,0.7)); color: var(--lv-loading-color, white); font-family: var(--lv-loading-font-family, system-ui, sans-serif); } [part="fallback-thumbnail"] { background: var(--lv-fallback-bg, #1a1a1a); color: var(--lv-fallback-color, white); font-family: var(--lv-fallback-font-family, system-ui, sans-serif); font-size: var(--lv-fallback-font-size, 14px); } `; } constructor() { super(); this.attachShadow({ mode: "open" }); this._loaded = false; this._placeholder = null; this._observer = null; this._handlers = new Map(); this._videoService = null; } connectedCallback() { if (!this.isConnected) return; if (!this._loaded && !this._placeholder) { this._createPlaceholder(); } // Setup autoloading if needed if (this._getServiceOption('autoload')) { this._setupObserver(); } // Check for alignment from CSS variables this._updateAlignmentFromCSS(); // Set up mutation observer for style changes this._setupStyleObserver(); } disconnectedCallback() { this._cleanupObserver(); this._cleanupEventHandlers(); // Clean up style observer if (this._styleObserver) { this._styleObserver.disconnect(); this._styleObserver = null; } // Cancel any animation frames if (this._styleFrameId) { cancelAnimationFrame(this._styleFrameId); } } attributeChangedCallback(name, oldValue, newValue) { if (!this.isConnected) return; switch (name) { case "src": if (oldValue !== newValue && newValue !== null) { this._loaded = false; this._createPlaceholder(); } break; case "width": case "height": this._updateStyles(); break; case "autoload": newValue === "true" || newValue === "" ? this._setupObserver() : this._cleanupObserver(); break; case "thumbnail": if (oldValue !== newValue) { this._updateThumbnail(); } break; case "service": if (oldValue !== newValue) { this._loaded = false; this._createPlaceholder(); } break; } } _getServiceProvider(url) { // Check for explicit service attribute first const serviceName = this.getAttribute("service"); if (serviceName && VIDEO_SERVICES.has(serviceName)) { return VIDEO_SERVICES.get(serviceName); } // Auto-detect from URL if (url) { for (const provider of VIDEO_SERVICES.values()) { if (provider.canHandle(url)) { return provider; } } } // Default to YouTube if nothing else matches return VIDEO_SERVICES.get('youtube'); } _getServiceOption(option) { // First check if attribute exists if (this.hasAttribute(option)) { const value = this.getAttribute(option); // Handle boolean attributes return value === "" || value === "true" || value !== "false"; } // Then check service defaults if (this._videoService?.getDefaults()[option] !== undefined) { return this._videoService.getDefaults()[option]; } return false; } _cleanupObserver() { if (this._observer) { this._observer.disconnect(); this._observer = null; } } _cleanupEventHandlers() { this._handlers.forEach((handler, key) => { const [element, event] = key.split('|'); if (element && element.removeEventListener) { element.removeEventListener(event, handler); } }); this._handlers.clear(); } _setupObserver() { if (!window.IntersectionObserver) return; this._cleanupObserver(); this._observer = new IntersectionObserver(entries => { if (entries[0].isIntersecting && !this._loaded) { this._loadVideo(); this._cleanupObserver(); } }, { rootMargin: "300px", threshold: 0.1 }); this._observer.observe(this); } _updateThumbnail() { const img = this._placeholder?.querySelector('[part="thumbnail"]'); if (!img) return; const customThumbnail = this.getAttribute("thumbnail"); if (customThumbnail) { img.src = customThumbnail; // Remove any fallback thumbnail if present const fallback = this._placeholder.querySelector('[part="fallback-thumbnail"]'); if (fallback) fallback.remove(); return; } // Get service thumbnails const videoId = this._placeholder.dataset.videoId; if (videoId && this._videoService) { const thumbnailQuality = this.getAttribute("thumbnail-quality"); const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this); if (thumbnailUrls.length > 0) { this._loadThumbnail(thumbnailUrls, img); } else { this._createFallbackThumbnail(); } } } _createFallbackThumbnail() { if (!this._placeholder || this._placeholder.querySelector('[part="fallback-thumbnail"]')) { return; // Already exists or no placeholder } const fallback = document.createElement('div'); fallback.setAttribute('part', 'fallback-thumbnail'); // Service-specific branding if (this._videoService) { const serviceName = this._videoService.name; fallback.innerHTML = `
Error: Can't find video ID. Check the 'src' attribute.
`; return; } // Get parameters and create elements this._videoParams = this._videoService.parseParams(src); const title = this.getAttribute("title") || "Video"; // Build Shadow DOM const style = document.createElement("style"); style.textContent = LazyVideo.styles; const placeholder = this._buildPlaceholder(videoId, title); this.shadowRoot.innerHTML = ''; this.shadowRoot.append(style, placeholder); this._updateStyles(); } _buildPlaceholder(videoId, title) { // Create placeholder container const placeholder = document.createElement("div"); placeholder.setAttribute("part", "placeholder"); placeholder.setAttribute("role", "button"); placeholder.setAttribute("aria-label", `Play: ${title}`); placeholder.setAttribute("tabindex", "0"); placeholder.dataset.videoId = videoId; placeholder.dataset.service = this._videoService.name; this._placeholder = placeholder; // Create thumbnail image const thumbnailQuality = this.getAttribute("thumbnail-quality"); const thumbnailUrls = this._videoService.getThumbnailUrls(videoId, thumbnailQuality, this); // Add thumbnail image const img = document.createElement("img"); img.setAttribute("part", "thumbnail"); img.alt = `Thumbnail for ${title}`; img.loading = "lazy"; img.decoding = "async"; img.fetchPriority = "low"; img.style.backgroundColor = "#111"; placeholder.appendChild(img); // Start thumbnail loading process if (thumbnailUrls.length > 0) { this._setupThumbnailObserver(img, thumbnailUrls); } else { this._createFallbackThumbnail(); } // Add title bar if not disabled if (!this.hasAttribute("hide-title")) { const titleBar = document.createElement("div"); titleBar.setAttribute("part", "title-bar"); titleBar.textContent = title; placeholder.appendChild(titleBar); } // Add play button const playButton = document.createElement("div"); playButton.setAttribute("part", "play-button"); placeholder.appendChild(playButton); // Add timestamp if present in params const startTime = parseInt(this._videoParams.start || this._videoParams.t, 10); if (!isNaN(startTime) && startTime > 0) { const timestamp = document.createElement("div"); timestamp.setAttribute("part", "timestamp"); timestamp.textContent = this._formatTime(startTime); placeholder.appendChild(timestamp); } // Set up interaction handlers const handleInteraction = (e) => { if (e.type === "click" || e.key === "Enter" || e.key === " ") { if (e.type !== "click") e.preventDefault(); this._loadVideo(); } }; placeholder.addEventListener("click", handleInteraction); placeholder.addEventListener("keydown", handleInteraction); // Track handlers for cleanup this._handlers.set(`${placeholder}|click`, handleInteraction); this._handlers.set(`${placeholder}|keydown`, handleInteraction); return placeholder; } _setupThumbnailObserver(imgElement, urls) { if (!window.IntersectionObserver) { this._loadThumbnail(urls, imgElement); return; } this._thumbnailLoadAttempted = false; const observer = new IntersectionObserver(async (entries) => { if (entries[0].isIntersecting && !this._thumbnailLoadAttempted) { this._thumbnailLoadAttempted = true; try { await this._loadThumbnail(urls, imgElement); } catch { this._thumbnailLoadAttempted = false; } finally { observer.disconnect(); } } }, { rootMargin: "300px", threshold: 0.1 }); observer.observe(imgElement); } async _loadThumbnail(urls, imgElement) { // Custom thumbnails bypass validation if (urls.length === 1 && urls[0] === this.getAttribute("thumbnail")) { imgElement.src = urls[0]; return true; } // Cache key for shared thumbnails const videoId = this._placeholder?.dataset?.videoId; const service = this._placeholder?.dataset?.service; const cacheKey = videoId && service ? `${service}:${videoId}` : null; // Try to use cached result if (cacheKey && THUMBNAIL_REGISTRY.has(cacheKey)) { try { const bestUrl = await THUMBNAIL_REGISTRY.get(cacheKey); if (bestUrl) { imgElement.src = bestUrl; return true; } } catch { THUMBNAIL_REGISTRY.delete(cacheKey); } } // Find best thumbnail let bestUrl = null; // Try parallel loading first try { const results = await Promise.all( urls.map(url => checkImage(url) .then(valid => ({ url, valid })) .catch(() => ({ valid: false })) ) ); const bestResult = results.find(result => result.valid); if (bestResult) bestUrl = bestResult.url; } catch { // Try sequential loading if parallel fails for (const url of urls) { try { if (await checkImage(url)) { bestUrl = url; break; } } catch {} } } // Set the best URL or create fallback if (bestUrl) { imgElement.src = bestUrl; if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(bestUrl)); return true; } else { this._createFallbackThumbnail(); if (cacheKey) THUMBNAIL_REGISTRY.set(cacheKey, Promise.resolve(null)); return false; } } _formatTime(seconds) { const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return hours > 0 ? `${hours}:${minutes.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` : `${minutes}:${secs.toString().padStart(2, "0")}`; } _updateStyles() { const width = this.getAttribute("width"); const height = this.getAttribute("height"); // Helper to check if a value already includes a CSS unit const hasUnit = (value) => value && /[a-z%$]/.test(value); if (width) { this.style.setProperty("width", hasUnit(width) ? width : `${width}px`); } else { this.style.removeProperty("width"); } if (height) { this.style.setProperty("height", hasUnit(height) ? height : `${height}px`); } else { this.style.removeProperty("height"); } // For aspect ratio, use numeric values from width/height if possible if (width && height) { const numericWidth = parseFloat(width); const numericHeight = parseFloat(height); if (!isNaN(numericWidth) && !isNaN(numericHeight)) { this.style.setProperty("--lv-aspect-ratio", `${numericWidth} / ${numericHeight}`); } } } _loadVideo() { if (this._loaded || !this._placeholder) return; // Create loading indicator const loading = document.createElement("div"); loading.setAttribute("part", "loading"); loading.textContent = "Loading..."; this.shadowRoot.appendChild(loading); const videoId = this._placeholder.dataset.videoId; const title = this.getAttribute("title") || "Video"; // Get the service if not already set if (!this._videoService) { const serviceName = this._placeholder.dataset.service; this._videoService = VIDEO_SERVICES.get(serviceName) || VIDEO_SERVICES.get('youtube'); } // Get embed URL and create iframe const url = this._videoService.getEmbedUrl(videoId, this._videoParams, this); // Create iframe const iframe = document.createElement("iframe"); iframe.setAttribute("part", "iframe"); iframe.loading = "lazy"; iframe.src = url; iframe.title = title; // Add credentialless attribute for enhanced security iframe.setAttribute("credentialless", ""); // Add service-specific attributes const iframeAttrs = this._videoService.getIframeAttributes(this); for (const [name, value] of Object.entries(iframeAttrs)) { iframe.setAttribute(name, value); } // Handle loading indicator removal const loadHandler = () => loading.parentNode?.removeChild(loading); iframe.addEventListener("load", loadHandler, { once: true }); this._handlers.set(`${iframe}|load`, loadHandler); // Replace placeholder with iframe this._placeholder.replaceWith(iframe); this._loaded = true; this._placeholder = null; // Notify that video is loaded this.dispatchEvent(new CustomEvent("video-loaded", { bubbles: true, detail: { videoId, service: this._videoService.name } })); } _setupStyleObserver() { if (this._styleObserver) return; // Create a MutationObserver to watch for style attribute changes this._styleObserver = new MutationObserver(() => { this._updateAlignmentFromCSS(); }); this._styleObserver.observe(this, { attributes: true, attributeFilter: ['style'] }); // Also observe document/body style changes that might affect CSS variables if (window.getComputedStyle) { // Use requestAnimationFrame to limit performance impact let frameId; const checkStyles = () => { frameId = requestAnimationFrame(() => { this._updateAlignmentFromCSS(); frameId = requestAnimationFrame(checkStyles); }); }; checkStyles(); // Store the frame ID for cleanup this._styleFrameId = frameId; } } _updateAlignmentFromCSS() { if (this.hasAttribute('container-fit')) return; // Get computed style const computedStyle = window.getComputedStyle(this); const alignValue = computedStyle.getPropertyValue('--lv-align').trim(); // Remove existing alignment classes this.classList.remove('lv-align-left', 'lv-align-right', 'lv-align-center'); // Add appropriate class based on the CSS variable if (alignValue === 'left') { this.classList.add('lv-align-left'); } else if (alignValue === 'right') { this.classList.add('lv-align-right'); } else if (alignValue === 'center') { this.classList.add('lv-align-center'); } } } // Register the component if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => customElements.define("lazy-video", LazyVideo)); } else { customElements.define("lazy-video", LazyVideo); }