Import existing project
This commit is contained in:
parent
7887817595
commit
80b0cc4939
125 changed files with 16980 additions and 0 deletions
949
develop/js/lv.js
Normal file
949
develop/js/lv.js
Normal file
|
|
@ -0,0 +1,949 @@
|
|||
// 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);
|
||||
}
|
||||
Reference in a new issue