Показать сообщение отдельно
Старый Вчера, 16:25 Вверх   #12
Местный житель
 
Аватар для Razielik
Razielik вне форума
Доп. информация
Радость Новогодний подарок всем соискателям - расширенное управление embed плеерами (YT и PT)

Давным давно задался вопросом оптимизировать загрузку тяжелого содержимого форума. За недавнее время написал механизм, который постепенно дорабатывал.

Хочу, в качестве новогоднего подгона, для всех соискателей выложить результат нескольких бессонных ночей и потраченных выходных...

1. YouTube:
Шаблон BBcode_video:
Код:
<vb:elseif condition="$provider == 'youtube'" />
<div class="youtube-lazy" data-url="httрs://www.yоutube.com/watch?v={vb:raw code}" style="display: inline-block; width: 640px; height: 360px; cursor: pointer; background: black; position: relative;">
  <img loading="lazy" src="httрs://img.yоutube.com/vi/{vb:raw code}/hqdefault.jpg" alt="Щелкнуть для воспроизведения" style="width:100%; height:100%; object-fit: cover; display: block;">
  <div style="position: absolute; top: 50%; left: 50%; width: 80px; height: 80px; background: url('<путь к кнопке>.png') no-repeat center/contain; transform: translate(-50%, -50%); pointer-events: none;"></div>
</div>
<vb:elseif condition="$provider == 'youtube_share'" />
<div class="youtube-lazy" data-url="https://www.yоutube.com/watch?v={vb:raw code}" style="display: inline-block; width: 640px; height: 360px; cursor: pointer; background: black; position: relative;">
  <img loading="lazy" src="httрs://img.yоutube.com/vi/{vb:raw code}/hqdefault.jpg" alt="Щелкнуть для воспроизведения" style="width:100%; height:100%; object-fit: cover; display: block;">
  <div style="position: absolute; top: 50%; left: 50%; width: 80px; height: 80px; background: url('<путь к кнопке>.png') no-repeat center/contain; transform: translate(-50%, -50%); pointer-events: none;"></div>
</div>
Шаблон FOOTER:
Код:
<style>
.youtube-lazy {
  position: relative;
  cursor: pointer;
  background: #000;
  overflow: hidden;
  display: inline-block;
}

.youtube-lazy img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: opacity 0.3s ease;
}

.youtube-lazy .play-button {
  position: absolute;
  top: 50%;
  left: 50%;
  width: 80px;
  height: 80px;
  background: url('/multimedia/yt_plbtn.png') no-repeat center/contain;
  transform: translate(-50%, -50%);
  pointer-events: none;
  z-index: 20;
}

.youtube-overlay {
  position: absolute;
  bottom: 12px;
  left: 12px;
  right: 12px;
  background: rgba(0,0,0,0.7);
  color: white;
  padding: 12px 16px;
  border-radius: 8px;
  z-index: 10;
  opacity: 0;
  transition: opacity 0.4s ease;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
}

.youtube-overlay.show {
  opacity: 1;
}

.youtube-icon {
  width: 43px !important;
  height: 43px !important;
  flex-shrink: 0;
  background: url('/multimedia/pic/YT.png') center/contain no-repeat !important;
  background-color: transparent !important;
}

.youtube-title {
  font-weight: 600;
  font-size: clamp(14px, 4vw, 20px);
  line-height: 1.3;
  flex: 1;
  min-width: 0;
}

.youtube-author {
  font-size: 13px;
  opacity: 0.85;
  font-weight: 500;
}

.youtube-author a {
  color: white !important;
  text-decoration: none !important;
}

.youtube-separator {
  width: 2px;
  height: 32px;
  flex-shrink: 0;
  background: repeating-linear-gradient(
    to bottom,
    #fff 0px,
    #fff 2px,
    transparent 2px,
    transparent 6px
  );
}
</style>

<script>
document.addEventListener('DOMContentLoaded', function() {
  function smartLazyLoad(players) {
    const visibleVideos = [];
    const hiddenVideos = [];
    
    players.forEach(player => {
      const rect = player.getBoundingClientRect();
      if (rect.top < window.innerHeight && rect.bottom > 0) {
        visibleVideos.push(player);
      } else {
        hiddenVideos.push(player);
      }
    });
    
    visibleVideos.forEach(player => {
      player.classList.add('lazy-processed');
      const img = player.querySelector('img');
      if (img && !player.isUnavailable) {
        img.src = 'https://img.youtube.com/vi/' + player.videoId + '/maxresdefault.jpg';
      }
    });
    
    setTimeout(() => {
      hiddenVideos.forEach(player => {
        player.classList.add('lazy-processed');
        const img = player.querySelector('img');
        if (img && !player.isUnavailable) {
          img.src = 'https://img.youtube.com/vi/' + player.videoId + '/maxresdefault.jpg';
        }
      });
    }, 1000);
  }

  function getYouTubeID(url) {
    var regExp = /(?:youtube\.com.*(?:[?&]v=)|youtu\.be\/)([^&#]+)/;
    var match = url.match(regExp);
    return (match && match[1]) ? match[1] : null;
  }

  var players = document.querySelectorAll('.youtube-lazy');
  players.forEach(function(player) {
    var url = player.getAttribute('data-url') || '';

    var width = 640;
    var height = 360;

    player.style.width = width + 'px';
    player.style.height = height + 'px';

    var videoId = getYouTubeID(url);
    if (!videoId) return;

    player.isUnavailable = false;
    player.imgRetries = 0;
    player.videoId = videoId;
    player.thumbs = ['maxresdefault.jpg', 'hqdefault.jpg', 'sddefault.jpg', 'mqdefault.jpg', 'default.jpg'];

    var img = player.querySelector('img');
    if (img) {
      img.src = 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==';
      img.style.opacity = '0';
      
      img.onload = function() { 
        if (player.isUnavailable) return;
        
        if (this.naturalWidth > 120 || this.naturalHeight > 90) {
          this.style.opacity = '1';
        } else if (player.imgRetries < 4) {
          player.imgRetries++;
          this.src = 'https://img.youtube.com/vi/' + videoId + '/' + player.thumbs[player.imgRetries];
        }
      };
      
      img.onerror = function() {
        if (player.isUnavailable) return;
        if (player.imgRetries < 4) {
          player.imgRetries++;
          this.src = 'https://img.youtube.com/vi/' + videoId + '/' + player.thumbs[player.imgRetries];
        }
      };
      
      if ('IntersectionObserver' in window) {
        const observer = new IntersectionObserver((entries) => {
          entries.forEach(entry => {
            if (entry.isIntersecting && !player.isUnavailable && !player.classList.contains('lazy-processed')) {
              img.src = 'https://img.youtube.com/vi/' + player.videoId + '/maxresdefault.jpg';
              observer.unobserve(img);
            }
          });
        });
        observer.observe(img);
      }
    }

    var playButton = document.createElement('div');
    playButton.className = 'play-button';
    player.appendChild(playButton);

    function clickHandler() {
      var iframe = document.createElement('iframe');
      iframe.setAttribute('src', 'https://www.youtube.com/embed/' + videoId + '?autoplay=1&rel=0&modestbranding=1&playsinline=1&origin=https://[ВАШ_ДОМЕН]');
      iframe.setAttribute('frameborder', '0');
      iframe.setAttribute('allowfullscreen', '1');
      iframe.setAttribute('referrerpolicy', 'strict-origin-when-cross-origin');
      iframe.style.width = '100%';
      iframe.style.height = '100%';
      this.innerHTML = '';
      this.appendChild(iframe);
    }
    player.addEventListener('click', clickHandler);
    player._clickHandler = clickHandler;

    function showYouTubeLink() {
      var overlay = document.createElement('div');
      overlay.className = 'youtube-overlay';
      overlay.innerHTML = `
        <div class="youtube-icon"></div>
        <div class="youtube-title">Просмотр только на YouTube</div>
        <div class="youtube-separator"></div>
        <div class="youtube-author">
          <a href="ХТТПС://ВВВ.ЮТУБ.КОМ/watch?v=${videoId}" target="_blank">
            Перейти на YouTube
          </a>
        </div>
      `;
      player.appendChild(overlay);
      setTimeout(() => overlay.classList.add('show'), 400);
    }

    function showUnavailable() {
      player.isUnavailable = true;
      
      var img = player.querySelector('img');
      if (img) {
        img.style.display = 'none';
      }
      
      var ytImg = document.createElement('img');
      ytImg.src = `https://i.ytimg.com/vi/${videoId}/mqdefault.jpg`;
      ytImg.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;';
      ytImg.onerror = function() {
        this.style.background = '#000';
      };
      player.appendChild(ytImg);
      
      var overlay = document.createElement('div');
      overlay.className = 'youtube-overlay';
      overlay.innerHTML = `
        <div class="youtube-icon"></div>
        <div class="youtube-title">ВИДЕО НЕДОСТУПНО</div>
        <div class="youtube-separator"></div>
        <div class="youtube-author">Видео удалено или недоступно</div>
      `;
      player.appendChild(overlay);
      setTimeout(() => overlay.classList.add('show'), 400);
      
      player.style.cursor = 'default';
      player.removeEventListener('click', player._clickHandler);
    }

    function loadMetadata(retries = 0) {
      fetch('https://www.youtube.com/oembed?url=ХТТПС://ВВВ.ЮТУБ.КОМ/watch?v=' + videoId + '&format=json')
        .then(response => {
          if (!response.ok) throw new Error('no');
          return response.json();
        })
        .then(oembed => {
          if (oembed.title && oembed.author_name) {
            var overlay = document.createElement('div');
            overlay.className = 'youtube-overlay';
            overlay.innerHTML = `
              <div class="youtube-icon" alt="YouTube"></div>
              <div class="youtube-title">${oembed.title}</div>
              <div class="youtube-separator"></div>
              <div class="youtube-author">${oembed.author_name}</div>
            `;
            player.appendChild(overlay);
            setTimeout(() => overlay.classList.add('show'), 400);
          } else retryOrFallback();
        })
        .catch(() => retryOrFallback());

      function retryOrFallback() {
        if (retries < 3) {
          setTimeout(() => loadMetadata(retries + 1), (retries + 1) * 400);
        } else {
          const checkImage = () => {
            var img = player.querySelector('img');
            if (img && img.complete && img.naturalWidth > 120 && img.naturalHeight > 90) { 
              showYouTubeLink(); 
            } else if (img && img.complete) {
              showUnavailable(); 
            } else {
              setTimeout(checkImage, 500);
            }
          };
          setTimeout(checkImage, 1000);
        }
      }
    }

    loadMetadata(0);
  });

  setTimeout(() => {
    smartLazyLoad(Array.from(document.querySelectorAll('.youtube-lazy')));
  }, 100);
});
</script>
Результат:






2. Также есть такой, стремительно набирающий популярность феномен как PeerTube:
BB код замены:
Код:
<div class="peertube-lazy" data-url="{param}" style="width:640px; height:360px; display:inline-block; vertical-align:top;"></div>
Шаблон FOOTER:
Код:
<style>
.peertube-wrapper {
  position: relative;
  width: 100%;
  max-width: 640px;
  padding-bottom: 56.25%;
  height: 0;
  background-color: black;
  display: inline-block !important;
  vertical-align: top;
  color: white;
  font-weight: bold;
  overflow: hidden;
  box-sizing: border-box;
  float: none !important;
  clear: both;
  line-height: 0;
}

.peertube-wrapper iframe.peertube-player {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border: none;
  display: block;
}

.peertube-wrapper .error-message {
  text-align: center;
  color: #ff4444;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 8px;
  width: 100%;
  height: 100%;
}

.peertube-wrapper .error-message img {
  max-width: 80px;
  height: auto;
}

.peertube-lazy {
  display: inline-block !important;
  vertical-align: top !important;
  max-width: 640px;
  line-height: 0 !important;
}

table .peertube-lazy, .postcontent .peertube-lazy, .postbody .peertube-lazy {
  display: inline-block !important;
  vertical-align: top !important;
}
</style>

<script>
const videoIdCache = new Map();
const embedUrlCache = new Map();
let lastMutationCheck = 0;
const THROTTLE_DELAY = 500;

const isAndroid = /Android/i.test(navigator.userAgent);
let androidVideosLoaded = 0;
let androidScrollReset = 0;

function initPeertubeContainer(container) {
  if (container.dataset.processed) return;
  container.dataset.processed = 'true';

  if (isAndroid) {
    if (container.dataset.blocked) return;
    if (androidVideosLoaded >= 4) {
      container.dataset.blocked = 'true';
      container.innerHTML = '';
      container.style.position = 'relative';
      container.style.background = '#000';
      container.innerHTML = `<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #000; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #ff6b6b; font-size: 14px; padding: 20px; text-align: center;">
        <img src="https://[ВАШ ДОМЕН]/client/assets/images/mascot/defeated.svg" style="width: 50px; height: auto; margin-bottom: 10px;">
        <div style="font-weight: bold;">Много видео</div>
        <small style="color: #999; font-size: 12px;">Первые 4 загружены</small>
      </div>`;
      return;
    }
  }

  const loadIframe = () => {
    const fullUrl = container.getAttribute('data-url');
    const errorImageUrl = 'https://[ВАШ ДОМЕН]/client/assets/images/mascot/defeated.svg';

    if (isAndroid) {
      if (container._debounceTimeout) clearTimeout(container._debounceTimeout);
      container._debounceTimeout = setTimeout(() => {
        if (container.contains(document.querySelector('[id^="loading-"]'))) return;
        container.innerHTML = `<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #000; display: flex; align-items: center; justify-content: center; color: #666; font-size: 14px; z-index: 10;" id="loading-${Date.now()}">Загрузка...</div>`;
        container.style.position = 'relative';
        container.style.background = '#000';
        loadIframeContent(fullUrl, errorImageUrl, container);
      }, 100);
    } else {
      container.innerHTML = `<div style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: #000; display: flex; align-items: center; justify-content: center; color: #666; font-size: 14px; z-index: 10;" id="loading-${Date.now()}">Загрузка...</div>`;
      container.style.position = 'relative';
      container.style.background = '#000';
      loadIframeContent(fullUrl, errorImageUrl, container);
    }
  };

  const loadIframeContent = (fullUrl, errorImageUrl, container) => {
    setTimeout(() => {
      try {
        const url = new URL(fullUrl);
        let videoId = videoIdCache.get(fullUrl);
        
        if (!videoId) {
          const pathParts = url.pathname.split('/');
          videoId = pathParts[pathParts.length - 1];
          while (videoId === '' || videoId === 'watch' || videoId === 'videos') {
            pathParts.pop();
            videoId = pathParts[pathParts.length - 1];
          }
          videoIdCache.set(fullUrl, videoId);
        }
        
        if (!videoId) throw new Error('Не найден videoId');

        let embedUrl = embedUrlCache.get(videoId);
        if (!embedUrl) {
          embedUrl = `${url.origin}/videos/embed/${videoId}?autoplay=0&muted=0&loop=0&controlBar=1&fullScreenButton=1&cc=0&title=1&warningTitle=0`;
          embedUrlCache.set(videoId, embedUrl);
        }

        const iframe = document.createElement('iframe');
        iframe.src = embedUrl;
        iframe.width = '100%';
        iframe.height = '100%';
        iframe.frameBorder = 0;
        iframe.allowFullscreen = true;
        iframe.loading = 'lazy';
        iframe.allow = 'autoplay; fullscreen';
        iframe.style.position = 'absolute';
        iframe.style.top = '0';
        iframe.style.left = '0';
        iframe.style.zIndex = '5';
        iframe.style.background = 'black';
        iframe.style.opacity = '0';

        let iframeLoaded = false;
        iframe.onload = () => {
          iframeLoaded = true;
          if (isAndroid) androidVideosLoaded++;
          const loadingEl = container.querySelector('[id^="loading-"]');
          if (loadingEl) loadingEl.remove();
          iframe.style.opacity = '1';
          clearTimeout(container._debounceTimeout);
        };

        iframe.onerror = () => {};

        container.appendChild(iframe);

        setTimeout(() => {
          if (!iframeLoaded) {
            const loadingEl = container.querySelector('[id^="loading-"]');
            if (loadingEl) loadingEl.remove();
            if (!container.querySelector('iframe[src]')) {
              container.innerHTML = `<div style="text-align:center; padding: 20px;">
                <img src="${errorImageUrl}" alt="Ошибка" style="max-width:100%; height:auto;">
                <div style="color: red; font-weight: bold; margin-top: 8px;">Видео недоступно</div>
              </div>`;
            }
          }
        }, isAndroid ? 7000 : 5000);

      } catch (e) {
        const loadingEl = container.querySelector('[id^="loading-"]');
        if (loadingEl) loadingEl.remove();
        container.innerHTML = `<div style="text-align:center; padding: 20px;">
          <img src="${errorImageUrl}" alt="Ошибка" style="max-width:100%; height:auto;">
          <div style="color: red; font-weight: bold; margin-top: 8px;">${e.message}</div>
        </div>`;
      }
    }, 10);
  };

  if ('IntersectionObserver' in window) {
    const observer = new IntersectionObserver(entries => {
      entries.forEach(entry => {
        if (entry.isIntersecting && !entry.target.dataset.loadingStarted) {
          entry.target.dataset.loadingStarted = 'true';
          loadIframe();
          observer.unobserve(container);
        }
      });
    }, { 
      threshold: 0,
      rootMargin: isAndroid ? '200px 0px 400px 0px' : `${window.innerHeight * 8}px 0px ${window.innerHeight * 8}px 0px`
    });
    observer.observe(container);
  } else {
    loadIframe();
  }
}

let scrollObserver = null;
function setupScrollObserver() {
  if (scrollObserver || !('IntersectionObserver' in window)) return;
  
  scrollObserver = new IntersectionObserver(entries => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        const container = entry.target;
        if (!container.dataset.processed) {
          initPeertubeContainer(container);
        }
      }
    });
  }, {
    threshold: 0,
    rootMargin: `${window.innerHeight * 10}px 0px ${window.innerHeight * 10}px 0px`
  });

  requestAnimationFrame(() => {
    document.querySelectorAll('.peertube-lazy').forEach(container => {
      scrollObserver.observe(container);
    });
  });
}

const mutationObserver = new MutationObserver((mutations) => {
  const now = Date.now();
  if (now - lastMutationCheck < THROTTLE_DELAY) return;
  lastMutationCheck = now;
  
  requestAnimationFrame(() => {
    let hasNewVideos = false;
    mutations.forEach((mutation) => {
      mutation.addedNodes.forEach((node) => {
        if (node.nodeType === 1) {
          const newContainers = node.querySelectorAll('.peertube-lazy');
          newContainers.forEach(container => {
            initPeertubeContainer(container);
            if (scrollObserver) scrollObserver.observe(container);
          });
          if (newContainers.length > 0) hasNewVideos = true;
        }
      });
    });
    
    if (hasNewVideos) {
      setupScrollObserver();
    }
  });
});
mutationObserver.observe(document.body, { childList: true, subtree: true });

setTimeout(() => {
  setupScrollObserver();
}, 800);

let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    requestAnimationFrame(() => {
      setupScrollObserver();
      if (isAndroid && Math.abs(window.scrollY - androidScrollReset) > 500) {
        androidVideosLoaded = 0;
        androidScrollReset = window.scrollY;
      }
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });

setTimeout(() => {
  const allVideos = document.querySelectorAll('.peertube-lazy:not([data-processed])');
  
  if (isAndroid) {
    androidVideosLoaded = 0;
    allVideos.forEach((container, index) => {
      setTimeout(() => {
        if (!container.dataset.processed && !container.dataset.blocked) {
          initPeertubeContainer(container);
        }
      }, index * 200);
    });
  } else {
    allVideos.forEach((container, index) => {
      const delay = (index % 5) * 50;
      setTimeout(() => {
        initPeertubeContainer(container);
      }, delay);
    });
  }
}, 1500);
</script>
Результат:


Как общий результат: очень быстрая загрузка содержимого страниц вашего форума vBulletin 4.x.x! Даже если на странице запощено очень много видео. Бонусом отдельно идет оптимизация под мобильные платформы, т.к. механизм у них чуточку другой (с этим я дополнительно помучился).

Не забудьте подправить пути на ресурсы в скриптах и нарисовать свои иконки для YT-варианта. (Также исправьте "ХТТПС://ВВВ.ЮТУБ.КОМ" на нормальный адрес, а то почему-то здешний BB код "CODE" не экранирует всё как надо.)

С наступающим всех!!!

Последний раз редактировалось Razielik; Вчера в 20:11..
  Ответить с цитированием
Cказали cпасибо:
 
Время генерации страницы 0.07028 секунды с 9 запросами