如何创建完全自定义的 Video.js UI 组件。
有时,内置的 UI 组件不能完全满足你的需求。Video.js 允许你构建完全自定义的组件,同时仍然利用播放器的状态管理和交互处理。
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>
);
}
useCallback 和 useMemo 优化事件处理程序上一步: 自定义皮肤