构建自定义 UI 组件

预计阅读时间: 6 分钟

如何创建完全自定义的 Video.js UI 组件。

有时,内置的 UI 组件不能完全满足你的需求。Video.js 允许你构建完全自定义的组件,同时仍然利用播放器的状态管理和交互处理。

自定义组件基础

使用 usePlayer Hook

usePlayer hook 是构建自定义组件的核心。它让你可以访问播放器的状态和方法。

import { usePlayer } from '@videojs/react';

function CustomPlayButton() {
  // 订阅特定状态
  const { paused, play, pause } = usePlayer((s) => ({
    paused: s.paused,
    play: s.play,
    pause: s.pause,
  }));

  return (
    <button
      onClick={() => paused ? play() : pause()}
      className="custom-play-button"
    >
      {paused ? '▶️ 播放' : '⏸️ 暂停'}
    </button>
  );
}

使用功能选择器

对于更精确的状态选择,使用功能选择器:

import { usePlayer, selectPlayback, selectVolume } from '@videojs/react';

function PlaybackStatus() {
  // 只订阅 playback 相关状态
  const playback = usePlayer(selectPlayback);
  
  // 只订阅 volume 相关状态
  const volume = usePlayer(selectVolume);

  return (
    <div className="status-bar">
      <span>{playback?.paused ? '已暂停' : '播放中'}</span>
      <span>音量: {Math.round((volume?.volume || 0) * 100)}%</span>
    </div>
  );
}

示例:自定义播放按钮

创建一个带有动画效果的自定义播放按钮:

// components/AnimatedPlayButton.tsx
import { usePlayer } from '@videojs/react';
import { useState } from 'react';

export function AnimatedPlayButton() {
  const [isAnimating, setIsAnimating] = useState(false);
  const { paused, play, pause } = usePlayer((s) => ({
    paused: s.paused,
    play: s.play,
    pause: s.pause,
  }));

  const handleClick = () => {
    setIsAnimating(true);
    setTimeout(() => setIsAnimating(false), 300);
    
    if (paused) {
      play();
    } else {
      pause();
    }
  };

  return (
    <button
      onClick={handleClick}
      className={`animated-play-button ${isAnimating ? 'animating' : ''}`}
      aria-label={paused ? '播放' : '暂停'}
    >
      <div className={`icon ${paused ? 'play' : 'pause'}`}>
        {paused ? (
          <svg viewBox="0 0 24 24">
            <path d="M8 5v14l11-7z" />
          </svg>
        ) : (
          <svg viewBox="0 0 24 24">
            <path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" />
          </svg>
        )}
      </div>
    </button>
  );
}
/* styles/animated-button.css */
.animated-play-button {
  width: 64px;
  height: 64px;
  border-radius: 50%;
  border: none;
  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: transform 0.2s, box-shadow 0.2s;
}

.animated-play-button:hover {
  transform: scale(1.1);
  box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
}

.animated-play-button.animating {
  animation: pulse 0.3s ease-out;
}

.animated-play-button .icon {
  width: 28px;
  height: 28px;
  fill: white;
  transition: transform 0.2s;
}

.animated-play-button.animating .icon {
  transform: scale(0.9);
}

@keyframes pulse {
  0% { transform: scale(1); }
  50% { transform: scale(0.95); }
  100% { transform: scale(1); }
}

示例:自定义音量控制

创建一个垂直的音量滑块:

// components/VerticalVolumeControl.tsx
import { usePlayer, selectVolume } from '@videojs/react';
import { useState, useRef, useCallback } from 'react';

export function VerticalVolumeControl() {
  const volume = usePlayer(selectVolume);
  const [isDragging, setIsDragging] = useState(false);
  const containerRef = useRef<HTMLDivElement>(null);

  const handleInteraction = useCallback((clientY: number) => {
    if (!containerRef.current || !volume) return;
    
    const rect = containerRef.current.getBoundingClientRect();
    const percentage = 1 - (clientY - rect.top) / rect.height;
    const clampedPercentage = Math.max(0, Math.min(1, percentage));
    
    volume.setVolume(clampedPercentage);
  }, [volume]);

  const handleMouseDown = (e: React.MouseEvent) => {
    setIsDragging(true);
    handleInteraction(e.clientY);
  };

  const handleMouseMove = (e: React.MouseEvent) => {
    if (isDragging) {
      handleInteraction(e.clientY);
    }
  };

  const handleMouseUp = () => {
    setIsDragging(false);
  };

  const toggleMute = () => {
    if (volume) {
      if (volume.volume > 0) {
        volume.setVolume(0);
      } else {
        volume.setVolume(1);
      }
    }
  };

  const percentage = (volume?.volume || 0) * 100;

  return (
    <div className="vertical-volume-control">
      <button 
        onClick={toggleMute}
        className="volume-icon"
        aria-label={percentage === 0 ? '取消静音' : '静音'}
      >
        {percentage === 0 ? '🔇' : percentage < 50 ? '🔉' : '🔊'}
      </button>
      
      <div
        ref={containerRef}
        className="volume-slider-vertical"
        onMouseDown={handleMouseDown}
        onMouseMove={handleMouseMove}
        onMouseUp={handleMouseUp}
        onMouseLeave={handleMouseUp}
      >
        <div className="volume-track">
          <div 
            className="volume-fill"
            style={{ height: `${percentage}%` }}
          />
        </div>
        <div 
          className="volume-thumb"
          style={{ bottom: `${percentage}%` }}
        />
      </div>
      
      <span className="volume-percentage">{Math.round(percentage)}%</span>
    </div>
  );
}
/* styles/volume-control.css */
.vertical-volume-control {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 12px;
  background: rgba(0, 0, 0, 0.8);
  border-radius: 8px;
}

.volume-icon {
  background: none;
  border: none;
  font-size: 20px;
  cursor: pointer;
  padding: 4px;
}

.volume-slider-vertical {
  width: 24px;
  height: 120px;
  position: relative;
  cursor: pointer;
}

.volume-track {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  width: 4px;
  height: 100%;
  background: rgba(255, 255, 255, 0.3);
  border-radius: 2px;
  overflow: hidden;
}

.volume-fill {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  background: linear-gradient(to top, #667eea, #764ba2);
  transition: height 0.1s;
}

.volume-thumb {
  position: absolute;
  left: 50%;
  transform: translate(-50%, 50%);
  width: 16px;
  height: 16px;
  background: white;
  border-radius: 50%;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
  transition: bottom 0.1s;
}

.volume-percentage {
  color: white;
  font-size: 12px;
  font-weight: 500;
}

示例:自定义缓冲指示器

创建一个带有加载动画的缓冲指示器:

// components/CustomBufferingIndicator.tsx
import { usePlayer, selectBuffer } from '@videojs/react';

export function CustomBufferingIndicator() {
  const buffer = usePlayer(selectBuffer);
  
  if (!buffer?.waiting) {
    return null;
  }

  return (
    <div className="buffering-overlay">
      <div className="buffering-spinner">
        <div className="spinner-ring" />
        <div className="spinner-ring" />
        <div className="spinner-ring" />
      </div>
      <p className="buffering-text">加载中...</p>
    </div>
  );
}
/* styles/buffering.css */
.buffering-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.6);
  backdrop-filter: blur(4px);
}

.buffering-spinner {
  position: relative;
  width: 60px;
  height: 60px;
}

.spinner-ring {
  position: absolute;
  inset: 0;
  border: 3px solid transparent;
  border-top-color: #667eea;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

.spinner-ring:nth-child(2) {
  inset: 8px;
  border-top-color: #764ba2;
  animation-duration: 1.5s;
  animation-direction: reverse;
}

.spinner-ring:nth-child(3) {
  inset: 16px;
  border-top-color: #f093fb;
  animation-duration: 2s;
}

.buffering-text {
  margin-top: 16px;
  color: white;
  font-size: 14px;
  animation: pulse-text 1.5s ease-in-out infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

@keyframes pulse-text {
  0%, 100% { opacity: 0.6; }
  50% { opacity: 1; }
}

使用自定义组件

将自定义组件组合到你的播放器中:

// App.tsx
import { createPlayer, videoFeatures } from '@videojs/react';
import { Video } from '@videojs/react/video';
import { CustomPlayButton } from './components/CustomPlayButton';
import { VerticalVolumeControl } from './components/VerticalVolumeControl';
import { CustomBufferingIndicator } from './components/CustomBufferingIndicator';
import './styles/custom-components.css';

const Player = createPlayer({ features: videoFeatures });

function App() {
  return (
    <Player.Provider>
      <Player.Container className="custom-player">
        <Video src="https://example.com/video.mp4" />
        
        <CustomBufferingIndicator />
        
        <div className="custom-controls">
          <CustomPlayButton />
          <VerticalVolumeControl />
        </div>
      </Player.Container>
    </Player.Provider>
  );
}

最佳实践

  1. 选择性订阅:只订阅组件需要的状态,避免不必要的重渲染
  2. 使用功能选择器:利用预构建的功能选择器获得更好的性能
  3. 保持可访问性:添加适当的 ARIA 标签和键盘支持
  4. 处理边界情况:考虑加载状态、错误状态和无功能支持的情况
  5. 优化性能:使用 useCallbackuseMemo 优化事件处理程序

下一步


上一步: 自定义皮肤