949 lines
No EOL
28 KiB
JavaScript
949 lines
No EOL
28 KiB
JavaScript
// 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 = `
|
|
<div style="text-align: center;">
|
|
<div style="font-size: 18px; margin-bottom: 8px;">${serviceName.charAt(0).toUpperCase() + serviceName.slice(1)}</div>
|
|
<div>Click to play video</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
fallback.textContent = 'No thumbnail available';
|
|
}
|
|
|
|
this._placeholder.appendChild(fallback);
|
|
}
|
|
|
|
async _createPlaceholder() {
|
|
const src = this.getAttribute("src");
|
|
|
|
// Determine service provider & video ID
|
|
this._videoService = this._getServiceProvider(src);
|
|
const videoId = this._videoService?.getVideoId(src);
|
|
|
|
if (!videoId) {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>:host{display:block;padding:10px;color:red;background:#222;border-radius:var(--lv-border-radius,0)}</style>
|
|
<p>Error: Can't find video ID. Check the 'src' attribute.</p>
|
|
`;
|
|
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);
|
|
} |