在前面的文章说,说明了服务端如何生成雪碧图/WebVTT,那如何在 Web 点播播放器上加载缩略图呢?

无论使用西瓜播放器,Video.js,Shaka Player,Plyr,DPlayer 等,以下方法都可以参考,有些播放器内置了支持雪碧图,有些播放器通过插件支持 WebVTT,但都无法达到我们想要的效果,那就…自己实现吧!

以下方案以 DPlayer 为例。

在视频元数据加载完成时,加载缩略图

initializeThumbnailPreview 用于初始化缩略图,有 4 个参数,分别是

  • webvtt 文件路径
  • 播放器的 video 标签
  • 父级容器( 可以对 div 设置 ref,通过 ref 获取 )
  • 错误回调
    // 播放器基础事件
    player.on('loadedmetadata', async () => {
      logger.info('🍒 视频元数据加载完成', {
        duration: player.video.duration.toFixed(2),
        videoWidth: player.video.videoWidth,
        videoHeight: player.video.videoHeight
      });

      // 初始化缩略图预览功能
      if (vttPath && player.video && container) {
        try {
          const thumbnailState = await initializeThumbnailPreview({
            vttPath,
            videoElement: player.video,
            videoContainer: container,
            onError
          });
          resolve(thumbnailState);
        } catch (error) {
          logger.error('🍒 缩略图预览初始化失败', error);
          resolve(null);
        }
      } else {
        resolve(null);
      }
    });

重点要关注的是 setupThumbnailPreviewupdateThumbnailContent 函数,简单来说做了以下几个步骤

  1. 计算鼠标在进度条上的位置
  2. 在该位置上方显示缩略图
  3. 在缩略图里面显示进度

那么如何确定显示雪碧图中的哪个 tile 呢?

遍历 webvtt 文件,找到对应播放时间的缩略图,缩略图的后缀有 #xywh,之前的文章中有提到这个用于定位。图片元素显示是固定的 160*90,通过设置像素的位置对小图定位显示。

/**
 * 初始化缩略图预览功能
 * 这是主要的导出函数,供外部组件调用
 */
export const initializeThumbnailPreview = async (config: ThumbnailPreviewConfig): Promise<ThumbnailPreviewState> => {
  const { vttPath, videoElement, videoContainer, onError } = config;

  try {
    logger.info('🍒 开始初始化缩略图预览功能', {
      vttPath,
      videoElementReady: !!videoElement,
      containerReady: !!videoContainer
    });

    // 设置容器为相对定位
    videoContainer.style.position = 'relative';

    // 创建缩略图预览元素
    const { thumbnailElement, timeElement } = createThumbnailElements(videoContainer);

    // 加载 WebVTT 轨道
    await loadWebVTTTrack(vttPath, videoElement);

    // 设置缩略图预览功能
    await setupThumbnailPreview(videoElement, thumbnailElement, timeElement, videoContainer);

    // 创建清理函数
    const cleanup = () => {
      if (thumbnailElement && thumbnailElement.parentNode) {
        thumbnailElement.parentNode.removeChild(thumbnailElement);
        logger.info('🍒 缩略图预览元素已清理');
      }
    };

    logger.info('🍒 缩略图预览功能初始化完成');

    return {
      thumbnailElement,
      timeElement,
      cleanup
    };

  } catch (error) {
    const errorMessage = `缩略图预览功能初始化失败: ${error}`;
    logger.error('🍒 缩略图预览功能初始化失败', { error, vttPath });
    onError?.(errorMessage);
    throw error;
  }
};

loadWebVTTTrack 在 video 标签中添加 dom 元素,用于存储 webvtt 信息,方便后续读取与显示。

/**
 * 加载 WebVTT 缩略图轨道
 */
const loadWebVTTTrack = async (vttUrl: string, videoElement: HTMLVideoElement): Promise<void> => {
  try {
    logger.info('🍒 开始加载 WebVTT 缩略图', { vttUrl });

    // 使用 fetch 加载 WebVTT 内容
    const response = await fetch(vttUrl, {
      mode: 'cors',
      credentials: 'omit'
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    const vttContent = await response.text();
    logger.info('🍒 WebVTT 内容加载成功', {
      size: vttContent.length,
      firstLine: vttContent.split('\n')[0]
    });

    // 创建 Blob URL
    const blob = new Blob([vttContent], { type: 'text/vtt' });
    const blobUrl = URL.createObjectURL(blob);

    // 创建轨道元素
    const track = document.createElement('track');
    track.kind = 'metadata';
    track.label = 'thumbnails';
    track.default = true;
    track.src = blobUrl;

    // 添加轨道到视频元素
    videoElement.appendChild(track);

    // 监听轨道加载事件
    return new Promise((resolve, reject) => {
      track.addEventListener('load', () => {
        logger.info('🍒 WebVTT 轨道加载完成', { vttUrl });
        URL.revokeObjectURL(blobUrl);
        resolve();
      });

      track.addEventListener('error', (e) => {
        logger.error('🍒 WebVTT 轨道加载失败', { vttUrl, error: e });
        URL.revokeObjectURL(blobUrl);
        reject(new Error('WebVTT 轨道加载失败'));
      });
    });

  } catch (error) {
    logger.error('🍒 加载 WebVTT 失败', { vttUrl, error });
    throw error;
  }
};

setupThumbnailPreview 用于设置缩略图预览功能,主要是操作 dom 元素,修改 css 等。

为保证代码的完整性,我将在此处贴出完整函数,并通过注释的方式讲解内部设计。

/**
 * 设置缩略图预览功能(带重试机制)
 */
const setupThumbnailPreview = (
  videoElement: HTMLVideoElement,
  thumbnailElement: HTMLDivElement,
  timeElement: HTMLDivElement,
  videoContainer: HTMLElement
): Promise<void> => {
  return new Promise((resolve, reject) => {
    const attemptSetup = (attempt: number = 1): void => {
      const progressBar = findProgressBar();

      if (progressBar) {
        setupProgressBarEvents(progressBar, videoElement, thumbnailElement, timeElement, videoContainer);
        logger.info('🍒 缩略图预览功能设置成功', { attempt });
        resolve();
        return;
      }

      if (attempt <= RETRY_CONFIG.maxAttempts) {
        const delay = attempt * RETRY_CONFIG.baseDelay;
        logger.info('🍒 进度条元素未找到,延迟重试', {
          attempt,
          delay,
          maxAttempts: RETRY_CONFIG.maxAttempts
        });

        setTimeout(() => {
          // 调试信息:打印当前 DOM 中的相关元素
          const debugInfo = {
            dplayerElements: Array.from(document.querySelectorAll('[class*="dplayer"]')).map(el => ({
              tagName: el.tagName,
              className: el.className,
              visible: (el as HTMLElement).offsetParent !== null
            })),
            barElements: Array.from(document.querySelectorAll('[class*="bar"]')).map(el => ({
              tagName: el.tagName,
              className: el.className,
              visible: (el as HTMLElement).offsetParent !== null
            }))
          };

          logger.info('🍒 DOM 调试信息', { attempt, ...debugInfo });
          attemptSetup(attempt + 1);
        }, delay);
      } else {
        const error = new Error(`已达到最大重试次数 ${RETRY_CONFIG.maxAttempts},缩略图功能无法正常工作`);
        logger.warn('🍒 缩略图功能设置失败', { maxAttempts: RETRY_CONFIG.maxAttempts });
        reject(error);
      }
    };

    attemptSetup();
  });
};

更新缩略图的内容

/**
 * 更新缩略图内容和位置
 */
const updateThumbnailContent = (
  videoElement: HTMLVideoElement,
  thumbnailElement: HTMLDivElement,
  timeElement: HTMLDivElement,
  seekTime: number,
  thumbnailX: number
): void => {
  // 获取视频的文本轨道
  const tracks = videoElement.textTracks;
  if (!tracks || tracks.length === 0) {
    logger.warn('🍒 没有找到文本轨道', { tracksLength: tracks?.length });
    return;
  }

  const track = tracks[0];
  if (track.mode !== 'showing') {
    track.mode = 'showing';
  }

  if (!track.cues) {
    logger.warn('🍒 文本轨道没有 cues', {
      trackMode: track.mode,
      trackKind: track.kind
    });
    return;
  }

  // 查找当前时间对应的缩略图信息
  let activeCue = null;
  for (let i = 0; i < track.cues.length; i++) {
    const cue = track.cues[i];
    if (seekTime >= cue.startTime && seekTime <= cue.endTime) {
      activeCue = cue;
      break;
    }
  }

  if (!activeCue) {
    logger.warn('🍒 未找到对应时间的缩略图', { seekTime: seekTime.toFixed(2) });
    return;
  }

  // @ts-expect-error - VTTCue.text 包含缩略图信息
  const cueText = activeCue.text;

  // 解析 WebVTT 缩略图信息
  // 格式:image.jpg#xywh=160,90,160,90 或 url(image.jpg)
  let imageUrl = '';
  let xywh = '';

  if (cueText.includes('#xywh=')) {
    const parts = cueText.split('#xywh=');
    imageUrl = parts[0];
    xywh = parts[1];
  } else {
    const match = cueText.match(/url\(([^)]+)\)/);
    if (match) {
      imageUrl = match[1];
    } else {
      imageUrl = cueText.trim();
    }
  }

  if (!imageUrl) {
    logger.warn('🍒 无法解析缩略图URL', { cueText });
    return;
  }

  // 设置缩略图样式和位置
  if (xywh) {
    // 雪碧图模式:使用坐标信息
    const [x, y, w, h] = xywh.split(',').map(Number);
    thumbnailElement.style.backgroundImage = `url(${imageUrl})`;
    thumbnailElement.style.backgroundPosition = `-${x}px -${y}px`;
    thumbnailElement.style.backgroundSize = 'auto';
    thumbnailElement.style.width = `${w}px`;
    thumbnailElement.style.height = `${h}px`;

    logger.info('🍒 缩略图更新(雪碧图模式)', {
      seekTime: seekTime.toFixed(2),
      imageUrl: imageUrl.substring(imageUrl.lastIndexOf('/') + 1),
      spritePosition: `${x},${y}`,
      spriteSize: `${w}x${h}`,
      thumbnailX: thumbnailX.toFixed(1)
    });
  } else {
    // 单图模式
    thumbnailElement.style.backgroundImage = `url(${imageUrl})`;
    thumbnailElement.style.backgroundSize = 'cover';
    thumbnailElement.style.backgroundPosition = 'center';
    thumbnailElement.style.width = '160px';
    thumbnailElement.style.height = '90px';

    logger.info('🍒 缩略图更新(单图模式)', {
      seekTime: seekTime.toFixed(2),
      imageUrl: imageUrl.substring(imageUrl.lastIndexOf('/') + 1),
      thumbnailX: thumbnailX.toFixed(1)
    });
  }

  // 设置位置和时间
  thumbnailElement.style.left = `${thumbnailX}px`;
  timeElement.textContent = formatTime(seekTime);
};

希望你会喜欢 EasyDSS 点播模块

上面贴了很多代码,作为用户可以直接嵌入 iframe, 接入我们实现的播放器,开发者可以使用 EasyPlayer 。

点击查看 easydss 论坛

欢迎来到论坛一起讨论

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐