Files
video-player-demo/index.html.v6
2025-06-06 16:59:09 +08:00

853 lines
32 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>自定义视频音频播放器</title>
<script>
// const VIDEO_URL = 'http://localhost:8000/demo01.mp4';
// const VIDEO_URL = 'http://localhost:8000/demo02.mp4';
const VIDEO_URL = 'http://localhost:8000/demo03.mp4';
</script>
<style>
.container {
max-width: 1000px;
margin: 20px auto;
padding: 0 20px;
}
.video-box {
width: 100%;
position: relative;
}
video {
width: 100%;
border-radius: 8px;
}
.controls {
margin: 15px 0;
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.control-group {
margin: 10px 0;
padding: 10px;
border: 1px solid #eee;
border-radius: 6px;
background: #fafafa;
}
.control-group h4 {
margin: 0 0 8px 0;
font-size: 14px;
color: #333;
}
.radio-group {
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
}
.radio-item {
display: flex;
align-items: center;
gap: 5px;
}
.radio-item input[type="radio"] {
margin: 0;
}
.radio-item label {
font-size: 13px;
cursor: pointer;
user-select: none;
}
button {
padding: 8px 16px;
cursor: pointer;
border: 1px solid #ddd;
border-radius: 4px;
background: #f5f5f5;
}
button:hover {
background: #eee;
}
input[type="text"] {
flex: 1;
max-width: 400px;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
input[type="range"] {
width: 150px;
}
#visualizer {
border: 1px solid #eee;
margin-top: 20px;
width: 100%;
height: 150px;
border-radius: 4px;
}
.status {
color: #666;
margin-top: 5px;
}
</style>
</head>
<body>
<div class="container">
<h2>自定义视频音频播放器</h2>
<div class="status" id="statusText">正在自动加载视频...</div>
<div class="video-box">
<video id="video" crossorigin="anonymous"></video>
</div>
<div class="controls">
<button id="playBtn" disabled>播放</button>
<button id="pauseBtn" disabled>暂停</button>
<button id="muteBtn">静音</button>
<button id="testAudioBtn">测试音频</button>
<span>音量:</span>
<input type="range" id="volume" min="0" max="1" step="0.1" value="1">
</div>
<div class="control-group">
<h4>音轨选择</h4>
<div class="radio-group" id="trackRadioGroup">
<div class="radio-item">
<input type="radio" id="track0" name="audioTrack" value="0" checked>
<label for="track0">默认音轨</label>
</div>
</div>
</div>
<div class="control-group">
<h4>播放速度</h4>
<div class="radio-group">
<div class="radio-item">
<input type="radio" id="rate05" name="playbackRate" value="0.5">
<label for="rate05">0.5x</label>
</div>
<div class="radio-item">
<input type="radio" id="rate075" name="playbackRate" value="0.75">
<label for="rate075">0.75x</label>
</div>
<div class="radio-item">
<input type="radio" id="rate1" name="playbackRate" value="1" checked>
<label for="rate1">1x (正常)</label>
</div>
<div class="radio-item">
<input type="radio" id="rate125" name="playbackRate" value="1.25">
<label for="rate125">1.25x</label>
</div>
<div class="radio-item">
<input type="radio" id="rate15" name="playbackRate" value="1.5">
<label for="rate15">1.5x</label>
</div>
<div class="radio-item">
<input type="radio" id="rate2" name="playbackRate" value="2">
<label for="rate2">2x</label>
</div>
</div>
</div>
<div class="control-group">
<h4>音调变换</h4>
<div class="radio-group">
<div class="radio-item">
<input type="radio" id="pitch-12" name="pitchShift" value="-12">
<label for="pitch-12">降一个八度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch-7" name="pitchShift" value="-7">
<label for="pitch-7">降五度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch-5" name="pitchShift" value="-5">
<label for="pitch-5">降四度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch-3" name="pitchShift" value="-3">
<label for="pitch-3">降小三度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch-2" name="pitchShift" value="-2">
<label for="pitch-2">降大二度 (C→B♭)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch-1" name="pitchShift" value="-1">
<label for="pitch-1">降小二度 (C→B)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch0" name="pitchShift" value="0" checked>
<label for="pitch0">原调</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch1" name="pitchShift" value="1">
<label for="pitch1">升小二度 (C→C#)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch2" name="pitchShift" value="2">
<label for="pitch2">升大二度 (C→D)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch3" name="pitchShift" value="3">
<label for="pitch3">升小三度 (C→D#)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch4" name="pitchShift" value="4">
<label for="pitch4">升大三度 (C→E)</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch5" name="pitchShift" value="5">
<label for="pitch5">升四度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch7" name="pitchShift" value="7">
<label for="pitch7">升五度</label>
</div>
<div class="radio-item">
<input type="radio" id="pitch12" name="pitchShift" value="12">
<label for="pitch12">升一个八度</label>
</div>
</div>
</div>
<h3>音频频谱可视化</h3>
<canvas id="visualizer"></canvas>
</div>
<script>
const video = document.getElementById('video');
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const muteBtn = document.getElementById('muteBtn');
const testAudioBtn = document.getElementById('testAudioBtn');
const volumeInput = document.getElementById('volume');
const trackRadioGroup = document.getElementById('trackRadioGroup');
const statusText = document.getElementById('statusText');
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
// 固定视频地址 - 使用支持CORS的视频源
let audioContext;
let analyser;
let mediaElementSource;
let animationId;
let gainNode;
let pitchShifterNode;
let currentPitchShift = 0;
let isAudioContextInitialized = false;
// 设置画布尺寸
function resizeCanvas() {
canvas.width = canvas.clientWidth;
canvas.height = canvas.clientHeight;
}
// 创建音调变换处理器
function createPitchShifter(audioContext) {
const scriptProcessor = audioContext.createScriptProcessor(4096, 1, 1);
let phase = 0;
let lastInputSample = 0;
scriptProcessor.onaudioprocess = function (event) {
const inputBuffer = event.inputBuffer;
const outputBuffer = event.outputBuffer;
const inputData = inputBuffer.getChannelData(0);
const outputData = outputBuffer.getChannelData(0);
const pitchRatio = Math.pow(2, currentPitchShift / 12);
for (let i = 0; i < inputData.length; i++) {
if (currentPitchShift === 0) {
// 无音调变换,直接复制
outputData[i] = inputData[i];
} else {
// 简单的音调变换实现
// 这是一个基础实现,真实环境中可能需要更复杂的算法
const sampleIndex = i * pitchRatio;
const baseIndex = Math.floor(sampleIndex);
const fraction = sampleIndex - baseIndex;
if (baseIndex < inputData.length - 1) {
// 线性插值
outputData[i] = inputData[baseIndex] * (1 - fraction) +
inputData[baseIndex + 1] * fraction;
} else {
outputData[i] = lastInputSample;
}
}
}
if (inputData.length > 0) {
lastInputSample = inputData[inputData.length - 1];
}
};
return scriptProcessor;
}
// 应用音调变换
function applyPitchShift(semitones) {
currentPitchShift = semitones;
console.log('音调变换设置为:', semitones, '半音');
// 更新状态显示
if (!video.paused) {
const pitchText = semitones === 0 ? '原调' :
(semitones > 0 ? `升${semitones}半音` : `降${Math.abs(semitones)}半音`);
const currentRate = video.playbackRate;
const rateText = currentRate === 1 ? '正常速度' : `${currentRate}x 速度`;
statusText.textContent = `正在播放 (${rateText}, ${pitchText})`;
}
}
// 初始化音频上下文(需用户交互触发)
async function initAudioContext() {
try {
if (isAudioContextInitialized) {
console.log('音频上下文已经初始化,跳过');
return;
}
console.log('开始初始化音频上下文...');
audioContext = new (window.AudioContext || window.webkitAudioContext)();
// 如果音频上下文被暂停,恢复它
if (audioContext.state === 'suspended') {
await audioContext.resume();
console.log('音频上下文已恢复');
}
// 创建媒体元素源
mediaElementSource = audioContext.createMediaElementSource(video);
console.log('媒体元素源已创建');
// 创建分析器节点
analyser = audioContext.createAnalyser();
analyser.fftSize = 1024; // 增加分辨率
analyser.smoothingTimeConstant = 0.8;
console.log('分析器节点已创建fftSize:', analyser.fftSize);
// 创建增益节点(用于音量控制)
gainNode = audioContext.createGain();
gainNode.gain.value = 1.0;
console.log('增益节点已创建');
// 创建音调变换节点
pitchShifterNode = createPitchShifter(audioContext);
console.log('音调变换节点已创建');
// 连接音频节点链:视频 -> 音调变换 -> 分析器 -> 增益 -> 输出
mediaElementSource.connect(pitchShifterNode);
pitchShifterNode.connect(analyser);
analyser.connect(gainNode);
gainNode.connect(audioContext.destination);
console.log('音频节点链已连接(包含音调变换)');
isAudioContextInitialized = true;
console.log('音频上下文初始化成功!');
// 立即开始可视化
startVisualization();
// 测试音频数据
setTimeout(() => {
if (analyser) {
const testArray = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(testArray);
const hasData = testArray.some(value => value > 0);
console.log('测试音频数据 - 有数据:', hasData);
if (hasData) {
console.log('前10个频谱值:', Array.from(testArray.slice(0, 10)));
}
}
}, 2000);
} catch (error) {
console.error('音频上下文初始化失败:', error);
// 如果音频上下文初始化失败,至少确保视频能播放
isAudioContextInitialized = false;
}
}
// 启动可视化
function startVisualization() {
if (!analyser) {
console.warn('分析器节点不存在,启动模拟可视化');
startMockVisualization();
return;
}
// 如果已经在运行动画,先停止
if (animationId) {
cancelAnimationFrame(animationId);
}
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
console.log('可视化已启动,缓冲区长度:', bufferLength);
function draw() {
if (!analyser) {
console.warn('分析器节点丢失,停止绘制');
return;
}
// 获取频谱数据
analyser.getByteFrequencyData(dataArray);
// 检查是否有音频数据
let hasAudioData = false;
let maxValue = 0;
for (let i = 0; i < dataArray.length; i++) {
if (dataArray[i] > 0) {
hasAudioData = true;
maxValue = Math.max(maxValue, dataArray[i]);
}
}
// 清空画布
ctx.fillStyle = 'rgb(240, 240, 240)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
if (hasAudioData) {
console.log('检测到音频数据,最大值:', maxValue);
// 绘制频谱柱状图
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * canvas.height;
// 创建渐变色
const hue = (i / bufferLength) * 360;
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth - 1, barHeight);
x += barWidth;
}
} else {
// 显示"无音频数据"提示
ctx.fillStyle = '#999';
ctx.font = '16px Arial';
ctx.textAlign = 'center';
ctx.fillText('等待音频数据...', canvas.width / 2, canvas.height / 2);
// 如果视频正在播放但没有音频数据,可能有问题
if (!video.paused) {
console.log('视频正在播放但没有音频数据');
}
}
animationId = requestAnimationFrame(draw);
}
draw();
}
// 模拟可视化当Web Audio API不可用时
function startMockVisualization() {
console.log('启动模拟可视化');
function draw() {
if (video.paused) {
return;
}
// 清空画布
ctx.fillStyle = 'rgb(240, 240, 240)';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// 生成模拟频谱数据
const barCount = 50;
const barWidth = canvas.width / barCount;
for (let i = 0; i < barCount; i++) {
// 根据时间生成动态数据
const time = Date.now() * 0.001;
const frequency = i * 0.2;
const amplitude = Math.sin(time + frequency) * 0.5 + 0.5;
const barHeight = amplitude * canvas.height * 0.8;
// 创建渐变色
const hue = (i / barCount) * 360;
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`;
ctx.fillRect(i * barWidth, canvas.height - barHeight, barWidth - 1, barHeight);
}
animationId = requestAnimationFrame(draw);
}
draw();
}
// 停止可视化
function stopVisualization() {
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
}
// 自动加载视频
async function loadVideo() {
statusText.textContent = '加载中...';
statusText.style.color = '#666';
playBtn.disabled = true;
pauseBtn.disabled = true;
try {
// 使用固定的视频地址添加CORS支持
video.crossOrigin = 'anonymous';
video.src = VIDEO_URL;
console.log('开始加载视频:', VIDEO_URL);
await new Promise((resolve, reject) => {
video.onloadedmetadata = () => {
console.log('视频元数据加载完成');
console.log('视频时长:', video.duration);
console.log('视频尺寸:', video.videoWidth, 'x', video.videoHeight);
resolve();
};
video.onerror = (e) => {
console.error('视频加载错误:', e);
reject(e);
};
// 添加超时处理
setTimeout(() => reject(new Error('加载超时')), 15000);
});
statusText.textContent = '加载完成,可以开始播放';
statusText.style.color = 'green';
playBtn.disabled = false;
} catch (err) {
statusText.textContent = `加载失败:${err.message || '视频地址无效或网络错误'}`;
statusText.style.color = 'red';
console.error('视频加载错误:', err);
}
}
// 视频元数据加载完成后初始化音轨
video.addEventListener('loadedmetadata', () => {
console.log('视频元数据已加载');
// 检查并显示可用的音频轨道信息
const audioTracks = video.audioTracks;
const textTracks = video.textTracks;
console.log('AudioTracks数量:', audioTracks ? audioTracks.length : 0);
console.log('TextTracks数量:', textTracks ? textTracks.length : 0);
// 清空现有的音轨选项
trackRadioGroup.innerHTML = '';
// 尝试使用audioTracks
if (audioTracks && audioTracks.length > 0) {
console.log('使用AudioTracks');
for (let i = 0; i < audioTracks.length; i++) {
const track = audioTracks[i];
const radioItem = document.createElement('div');
radioItem.className = 'radio-item';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'audioTrack';
radio.id = `track${i}`;
radio.value = i;
radio.checked = track.enabled;
const label = document.createElement('label');
label.htmlFor = `track${i}`;
label.textContent = track.label || track.language || `音轨 ${i + 1}`;
radioItem.appendChild(radio);
radioItem.appendChild(label);
trackRadioGroup.appendChild(radioItem);
console.log(`音轨 ${i}:`, track.label, track.language, track.enabled);
// 添加音轨切换事件
radio.addEventListener('change', () => {
if (radio.checked) {
console.log('切换到音轨:', i);
// 禁用所有音轨
for (let j = 0; j < audioTracks.length; j++) {
audioTracks[j].enabled = false;
}
// 启用选中的音轨
audioTracks[i].enabled = true;
console.log('已启用音轨:', i);
}
});
}
}
// 如果没有多个音轨,显示默认选项
else {
const radioItem = document.createElement('div');
radioItem.className = 'radio-item';
const radio = document.createElement('input');
radio.type = 'radio';
radio.name = 'audioTrack';
radio.id = 'track0';
radio.value = '0';
radio.checked = true;
const label = document.createElement('label');
label.htmlFor = 'track0';
label.textContent = '默认音轨';
radioItem.appendChild(radio);
radioItem.appendChild(label);
trackRadioGroup.appendChild(radioItem);
console.log('只有一个默认音轨');
}
});
// 播放控制
playBtn.addEventListener('click', async () => {
try {
console.log('点击播放按钮');
// 初始化音频上下文(必须在用户交互后创建)
if (!isAudioContextInitialized) {
console.log('初始化音频上下文...');
await initAudioContext();
}
// 确保音频上下文在运行状态
if (audioContext && audioContext.state === 'suspended') {
await audioContext.resume();
console.log('音频上下文已恢复');
}
// 播放视频
await video.play();
console.log('视频开始播放');
// 更新按钮状态
playBtn.disabled = true;
pauseBtn.disabled = false;
statusText.textContent = '正在播放,音频频谱可视化已启动';
statusText.style.color = 'green';
} catch (err) {
console.error('播放失败:', err);
statusText.textContent = `播放失败:${err.message}`;
statusText.style.color = 'red';
}
});
pauseBtn.addEventListener('click', () => {
video.pause();
// 更新按钮状态
playBtn.disabled = false;
pauseBtn.disabled = true;
statusText.textContent = '视频已暂停';
statusText.style.color = '#666';
});
muteBtn.addEventListener('click', () => {
video.muted = !video.muted;
muteBtn.textContent = video.muted ? '取消静音' : '静音';
});
// 测试音频按钮
testAudioBtn.addEventListener('click', async () => {
console.log('=== 音频调试信息 ===');
console.log('视频元素状态:');
console.log('- 是否暂停:', video.paused);
console.log('- 是否静音:', video.muted);
console.log('- 音量:', video.volume);
console.log('- 当前时间:', video.currentTime);
console.log('- 总时长:', video.duration);
console.log('音频上下文状态:');
console.log('- 是否初始化:', isAudioContextInitialized);
console.log('- 状态:', audioContext ? audioContext.state : '未创建');
console.log('- 采样率:', audioContext ? audioContext.sampleRate : 'N/A');
console.log('音频节点状态:');
console.log('- MediaElementSource:', !!mediaElementSource);
console.log('- Analyser:', !!analyser);
console.log('- GainNode:', !!gainNode);
if (analyser) {
const testData = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(testData);
const hasData = testData.some(v => v > 0);
const maxVal = Math.max(...testData);
console.log('- 频谱数据:', hasData ? `有数据 (最大值: ${maxVal})` : '无数据');
}
// 尝试播放测试音频
if (!video.paused) {
console.log('视频正在播放,检查音频输出...');
setTimeout(() => {
if (analyser) {
const data = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(data);
console.log('延迟检查频谱数据:', data.slice(0, 10));
}
}, 1000);
}
});
volumeInput.addEventListener('input', (e) => {
const volume = parseFloat(e.target.value);
video.volume = volume;
// 如果有增益节点,也同步更新
if (gainNode) {
gainNode.gain.value = volume;
console.log('音量已更新:', volume);
}
});
// 播放速度控制
document.querySelectorAll('input[name="playbackRate"]').forEach(radio => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
const playbackRate = parseFloat(e.target.value);
video.playbackRate = playbackRate;
const rateText = playbackRate === 1 ? '正常速度' : `${playbackRate}x 速度`;
console.log('播放速度已改变为:', rateText);
// 如果视频正在播放,显示速度变化提示
if (!video.paused) {
const currentPitchShift = document.querySelector('input[name="pitchShift"]:checked')?.value || '0';
const pitchText = currentPitchShift === '0' ? '原调' :
(parseInt(currentPitchShift) > 0 ? `升${currentPitchShift}半音` : `降${Math.abs(parseInt(currentPitchShift))}半音`);
statusText.textContent = `正在播放 (${rateText}, ${pitchText})`;
statusText.style.color = '#4CAF50';
}
}
});
});
// 音调变换控制
document.querySelectorAll('input[name="pitchShift"]').forEach(radio => {
radio.addEventListener('change', (e) => {
if (e.target.checked) {
const pitchShift = parseInt(e.target.value);
applyPitchShift(pitchShift);
}
});
});
// 监听视频播放状态变化
video.addEventListener('play', async () => {
console.log('视频播放事件触发');
// 更新按钮状态
playBtn.disabled = true;
pauseBtn.disabled = false;
// 更新状态显示,包含当前播放速度和音调
const currentRate = video.playbackRate;
const rateText = currentRate === 1 ? '正常速度' : `${currentRate}x 速度`;
const currentPitchShift = document.querySelector('input[name="pitchShift"]:checked')?.value || '0';
const pitchText = currentPitchShift === '0' ? '原调' :
(parseInt(currentPitchShift) > 0 ? `升${currentPitchShift}半音` : `降${Math.abs(parseInt(currentPitchShift))}半音`);
statusText.textContent = `正在播放 (${rateText}, ${pitchText})`;
statusText.style.color = '#4CAF50';
// 检查音频状态
console.log('视频音量:', video.volume, '静音状态:', video.muted);
console.log('音频上下文状态:', audioContext ? audioContext.state : '未初始化');
if (audioContext && audioContext.state === 'suspended') {
await audioContext.resume();
console.log('音频上下文在播放时恢复');
}
if (isAudioContextInitialized && analyser) {
console.log('重新启动可视化');
startVisualization();
}
});
video.addEventListener('pause', () => {
console.log('视频暂停');
stopVisualization();
// 更新按钮状态
playBtn.disabled = false;
pauseBtn.disabled = true;
statusText.textContent = '视频已暂停';
statusText.style.color = '#666';
});
video.addEventListener('ended', () => {
console.log('视频播放结束');
stopVisualization();
// 重置按钮状态
playBtn.disabled = false;
pauseBtn.disabled = true;
statusText.textContent = '播放结束';
statusText.style.color = '#666';
});
// 添加音频相关的事件监听
video.addEventListener('volumechange', () => {
console.log('音量变化:', video.volume, '静音:', video.muted);
});
video.addEventListener('loadstart', () => {
console.log('开始加载视频');
});
video.addEventListener('canplay', () => {
console.log('视频可以播放');
console.log('视频时长:', video.duration);
console.log('视频是否有音频:', !video.muted && video.volume > 0);
});
// 画布尺寸适配
window.addEventListener('resize', resizeCanvas);
// 初始化画布尺寸
resizeCanvas();
// 如果DOM已经加载完成立即加载视频
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', loadVideo);
} else {
loadVideo();
}
</script>
</body>
</html>