Files
monibuca/plugin/hls/hls.js/fmp4.html
2025-02-26 09:46:05 +08:00

843 lines
27 KiB
HTML
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="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M3U8 to MP4 Player</title>
<style>
body {
max-width: 800px;
margin: 0 auto;
padding: 20px;
font-family: Arial, sans-serif;
}
.input-container {
margin-bottom: 20px;
}
input {
width: 70%;
padding: 8px;
margin-right: 10px;
margin-bottom: 10px;
display: block;
}
button {
padding: 8px 15px;
background-color: #4CAF50;
color: white;
border: none;
cursor: pointer;
margin-right: 10px;
}
button:hover {
background-color: #45a049;
}
video {
width: 100%;
margin-top: 20px;
}
#debug {
margin-top: 20px;
padding: 10px;
background-color: #f5f5f5;
border: 1px solid #ddd;
font-family: monospace;
white-space: pre-wrap;
}
.drop-zone {
width: 100%;
height: 100px;
border: 2px dashed #4CAF50;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 20px;
background-color: #f8f8f8;
transition: all 0.3s ease;
}
.drop-zone.drag-over {
background-color: #e8f5e9;
border-color: #2e7d32;
}
.drop-zone p {
margin: 0;
color: #666;
text-align: center;
}
</style>
</head>
<body>
<div class="input-container">
<input type="text" id="m3u8Url" placeholder="输入 M3U8 地址"
value="http://localhost:8080/hls/vod/fmp4.m3u8?start=1740116409&streamPath=live/test">
<button onclick="loadM3U8()">加载 M3U8</button>
<div id="m3u8Content"
style="margin: 10px 0; padding: 10px; background-color: #f0f0f0; border: 1px solid #ddd; font-family: monospace; white-space: pre; max-height: 200px; overflow-y: auto;">
</div>
<input type="text" id="fmp4Url" placeholder="输入 FMP4 地址">
<button onclick="testFMP4()">测试 FMP4</button>
<input type="text" id="wsUrl" placeholder="输入 WebSocket 地址" value="ws://localhost:8080/mp4/live/test.mp4">
<button onclick="connectWebSocket()">连接 WebSocket</button>
<div style="margin: 10px 0;">
<label for="bufferCount">缓存包数量: <span id="bufferCountValue">1</span></label>
<input type="range" id="bufferCount" min="1" max="50" value="1" style="width: 200px; margin-left: 10px;">
</div>
<div class="drop-zone" id="dropZone">
<p>拖放 FMP4 文件到这里<br>或点击选择文件</p>
<input type="file" id="fileInput" style="display: none" accept=".mp4,.fmp4">
</div>
</div>
<video id="videoPlayer" controls autoplay></video>
<div id="debug"></div>
<script>
// MSE Player Class
class MSEPlayer {
constructor(videoElement, onLog = console.log) {
this.video = videoElement;
this.mediaSource = null;
this.sourceBuffer = null;
this.pendingBuffers = [];
this.isBuffering = false;
this.onLog = onLog;
this.codecConfigs = [
// 'video/mp4; codecs="avc1.4d001f, mp4a.40.2"',
'video/mp4; codecs="avc1.4d001f"',
'video/mp4'
];
this.MAX_BUFFER_LENGTH = 30;
this.hasError = false;
this.isDestroyed = false;
this.retryCount = 0;
this.MAX_RETRIES = 3;
this.isSourceBufferReady = false;
this.hasMetadata = false;
}
log(message) {
this.onLog(message);
}
async init() {
if (this.mediaSource) {
if (this.mediaSource.readyState === 'open') {
try {
// 等待 SourceBuffer 更新完成
if (this.sourceBuffer && this.sourceBuffer.updating) {
await new Promise((resolve) => {
const onUpdate = () => {
this.sourceBuffer.removeEventListener('updateend', onUpdate);
resolve();
};
this.sourceBuffer.addEventListener('updateend', onUpdate);
});
}
// 不在这里调用 endOfStream而是等待视频元数据加载完成
} catch (e) {
this.log(`清理旧的 MediaSource 失败: ${e.message}`);
}
}
URL.revokeObjectURL(this.video.src);
this.log('清理旧的 MediaSource');
}
this.mediaSource = new MediaSource();
this.video.src = URL.createObjectURL(this.mediaSource);
this.pendingBuffers = [];
this.isBuffering = false;
this.hasError = false;
this.isDestroyed = false;
this.retryCount = 0;
this.isSourceBufferReady = false;
this.hasMetadata = false;
// 监听视频元数据加载事件
this.video.addEventListener('loadedmetadata', () => {
this.hasMetadata = true;
this.log('视频元数据已加载');
});
return new Promise((resolve, reject) => {
let timeoutId;
let sourceOpenHandler, errorHandler;
sourceOpenHandler = async () => {
this.log('MediaSource 已打开');
clearTimeout(timeoutId);
try {
await this.initSourceBuffer();
this.mediaSource.removeEventListener('sourceopen', sourceOpenHandler);
this.mediaSource.removeEventListener('error', errorHandler);
resolve();
} catch (error) {
this.log(`初始化失败: ${error.message}`);
this.handleError(error.message);
reject(error);
}
};
errorHandler = (e) => {
clearTimeout(timeoutId);
this.log(`MediaSource 错误: ${e}`);
this.mediaSource.removeEventListener('sourceopen', sourceOpenHandler);
this.mediaSource.removeEventListener('error', errorHandler);
this.handleError(e);
reject(e);
};
this.mediaSource.addEventListener('sourceopen', sourceOpenHandler);
this.mediaSource.addEventListener('error', errorHandler);
// 添加超时处理
timeoutId = setTimeout(() => {
if (!this.isDestroyed && this.mediaSource && this.mediaSource.readyState !== 'open') {
const error = new Error('MediaSource 打开超时');
this.mediaSource.removeEventListener('sourceopen', sourceOpenHandler);
this.mediaSource.removeEventListener('error', errorHandler);
this.handleError(error.message);
reject(error);
}
}, 5000); // 5秒超时
});
}
async initSourceBuffer() {
if (!this.mediaSource || this.mediaSource.readyState !== 'open') {
debugger;
throw new Error('MediaSource 未准备好');
}
let sourceBufferCreated = false;
for (const codec of this.codecConfigs) {
try {
if (MediaSource.isTypeSupported(codec)) {
this.sourceBuffer = this.mediaSource.addSourceBuffer(codec);
sourceBufferCreated = true;
this.log(`成功创建 SourceBuffer使用编解码器: ${codec}`);
break;
}
} catch (e) {
this.log(`尝试编解码器 ${codec} 失败: ${e.message}`);
}
}
if (!sourceBufferCreated) {
throw new Error('无法创建支持的 SourceBuffer');
}
this.sourceBuffer.mode = 'sequence';
const handleUpdateEnd = () => this.handleUpdateEnd();
const handleError = (e) => {
const errorMessage = e.message || e.toString();
this.log(`SourceBuffer 错误: ${errorMessage}`);
this.handleError(new Error(`SourceBuffer 错误: ${errorMessage}`));
};
this.sourceBuffer.addEventListener('updateend', handleUpdateEnd);
this.sourceBuffer.addEventListener('error', handleError);
// 保存事件处理函数的引用,以便在销毁时正确移除
this._updateEndHandler = handleUpdateEnd;
this._errorHandler = handleError;
// 等待一小段时间确保 SourceBuffer 完全准备好
await new Promise(resolve => setTimeout(resolve, 100));
this.isSourceBufferReady = true;
}
async appendBuffer(buffer) {
if (this.hasError || this.isDestroyed) {
this.log('播放器处于错误状态或已销毁,忽略新数据');
return;
}
// 如果 SourceBuffer 还未准备好,将数据加入队列
if (!this.isSourceBufferReady) {
if (this.pendingBuffers.length < 10) {
this.pendingBuffers.push(buffer);
this.log('SourceBuffer 未准备好,将数据加入队列');
}
return;
}
if (!this.sourceBuffer || this.sourceBuffer.updating || this.pendingBuffers.length > 0) {
if (this.pendingBuffers.length < 10) {
this.pendingBuffers.push(buffer);
this.log('缓冲区正忙,将数据加入队列');
} else {
this.log('等待队列已满,丢弃数据');
}
return;
}
try {
if (!buffer || buffer.byteLength === 0) {
throw new Error('收到空数据');
}
// 检查 MediaSource 状态
if (!this.mediaSource || this.mediaSource.readyState !== 'open') {
if (this.retryCount < this.MAX_RETRIES) {
this.retryCount++;
this.log(`MediaSource 未准备好,重试 ${this.retryCount}/${this.MAX_RETRIES}`);
this.pendingBuffers.unshift(buffer);
setTimeout(() => this.processNextBuffer(), 500);
return;
}
throw new Error('MediaSource 未准备好或已关闭');
}
await this.removeOldBuffers();
this.sourceBuffer.appendBuffer(buffer);
this.isBuffering = true;
this.retryCount = 0; // 重置重试计数
this.log(`添加数据到缓冲区,大小: ${buffer.byteLength} 字节`);
} catch (error) {
const errorMessage = error.message || '未知错误';
this.log(`添加缓冲区失败: ${errorMessage}`);
console.error('添加缓冲区失败:', error);
// 只有在重试次数用完后才触发致命错误
if (this.retryCount >= this.MAX_RETRIES) {
this.handleError(new Error(`添加缓冲区失败: ${errorMessage}`));
throw error;
}
}
}
async processNextBuffer() {
if (this.pendingBuffers.length > 0 && !this.sourceBuffer.updating) {
const nextBuffer = this.pendingBuffers.shift();
await this.appendBuffer(nextBuffer);
}
}
async removeOldBuffers() {
if (!this.sourceBuffer || !this.video.buffered.length) return;
const currentTime = this.video.currentTime;
const buffered = this.video.buffered;
for (let i = 0; i < buffered.length; i++) {
const start = buffered.start(i);
const end = buffered.end(i);
if (end - currentTime > this.MAX_BUFFER_LENGTH) {
const removeEnd = currentTime - 1;
if (removeEnd > start) {
try {
this.log(`清理缓冲区: ${start.toFixed(2)} - ${removeEnd.toFixed(2)}`);
await new Promise((resolve, reject) => {
this.sourceBuffer.remove(start, removeEnd);
const onUpdate = () => {
this.sourceBuffer.removeEventListener('updateend', onUpdate);
resolve();
};
this.sourceBuffer.addEventListener('updateend', onUpdate);
});
} catch (e) {
this.log(`清理缓冲区失败: ${e.message}`);
}
}
}
}
}
handleUpdateEnd() {
this.isBuffering = false;
this.log('缓冲区更新完成');
// 处理队列中的下一个缓冲区
this.processNextBuffer();
if (!this.hasError && !this.video.playing) {
this.video.play().catch(e => {
this.log(`播放失败: ${e.message}`);
this.handleError(e.message);
});
}
}
handleError(error) {
if (this.hasError || this.isDestroyed) {
return; // 防止重复处理错误
}
this.hasError = true;
const errorMessage = error instanceof Error ? error.message : String(error);
this.log(`播放器错误: ${errorMessage}`);
this.destroy().catch(e => {
this.log(`销毁播放器时发生错误: ${e.message}`);
});
}
async destroy() {
if (this.isDestroyed) {
return; // 防止重复销毁
}
this.isDestroyed = true;
this.hasError = true;
try {
this.video.pause();
// 等待 SourceBuffer 更新完成
if (this.sourceBuffer && this.sourceBuffer.updating) {
await new Promise((resolve) => {
const onUpdate = () => {
if (this.sourceBuffer) {
this.sourceBuffer.removeEventListener('updateend', onUpdate);
}
resolve();
};
this.sourceBuffer.addEventListener('updateend', onUpdate);
});
}
// 清理 SourceBuffer 事件监听器
if (this.sourceBuffer) {
if (this._updateEndHandler) {
this.sourceBuffer.removeEventListener('updateend', this._updateEndHandler);
}
if (this._errorHandler) {
this.sourceBuffer.removeEventListener('error', this._errorHandler);
}
}
// 清理 MediaSource
if (this.mediaSource && this.mediaSource.readyState === 'open') {
// 移除所有 SourceBuffers
if (this.sourceBuffer && !this.sourceBuffer.updating) {
try {
this.mediaSource.removeSourceBuffer(this.sourceBuffer);
} catch (e) {
this.log(`移除 SourceBuffer 失败: ${e.message}`);
}
}
// 只有在视频元数据加载完成后才调用 endOfStream
if (this.hasMetadata) {
try {
await new Promise(resolve => {
// 确保在下一个事件循环中执行 endOfStream
setTimeout(() => {
try {
this.mediaSource.endOfStream();
} catch (e) {
this.log(`关闭 MediaSource 失败: ${e.message}`);
}
resolve();
}, 0);
});
} catch (e) {
this.log(`关闭 MediaSource 失败: ${e.message}`);
}
}
}
if (this.video.src) {
URL.revokeObjectURL(this.video.src);
this.video.removeAttribute('src');
this.video.load();
}
} catch (error) {
this.log(`销毁播放器时发生错误: ${error.message}`);
} finally {
// 确保清理所有资源
this.sourceBuffer = null;
this.mediaSource = null;
this.pendingBuffers = [];
this.isBuffering = false;
this._updateEndHandler = null;
this._errorHandler = null;
this.hasMetadata = false;
}
}
}
// Global variables
let currentPlaylist = [];
let currentIndex = 0;
let msePlayer = null;
let wsConnection = null;
let bufferMergeCount = 10; // 默认缓存包数量
// 添加滑动条事件监听
const bufferCountSlider = document.getElementById('bufferCount');
const bufferCountValue = document.getElementById('bufferCountValue');
bufferCountSlider.addEventListener('input', (e) => {
bufferMergeCount = parseInt(e.target.value);
bufferCountValue.textContent = bufferMergeCount;
if (wsConnection) {
log(`已更新缓存包数量为: ${bufferMergeCount}`);
}
});
function log(message) {
const debug = document.getElementById('debug');
const time = new Date().toLocaleTimeString();
const newLine = document.createElement('div');
newLine.innerHTML = `[${time}] ${message}`;
if (debug.firstChild) {
debug.insertBefore(newLine, debug.firstChild);
} else {
debug.appendChild(newLine);
}
}
// Initialize MSE player
function initPlayer() {
const video = document.getElementById('videoPlayer');
if (msePlayer) {
msePlayer.destroy();
}
msePlayer = new MSEPlayer(video, log);
return msePlayer.init();
}
// WebSocket handling
async function connectWebSocket() {
const wsUrl = document.getElementById('wsUrl').value;
if (!wsUrl) {
alert('请输入 WebSocket 地址');
return;
}
try {
await initPlayer();
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
wsConnection = new WebSocket(wsUrl);
wsConnection.binaryType = 'arraybuffer';
log(`正在连接 WebSocket: ${wsUrl}`);
wsConnection.onopen = () => {
log('WebSocket 连接已建立');
};
wsConnection.onmessage = async (event) => {
if (!msePlayer || msePlayer.hasError || msePlayer.isDestroyed) {
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
return;
}
try {
if (!event.data || event.data.byteLength === 0) {
throw new Error('收到空数据');
}
// 为前两个 buffer 创建 Blob URL
if (!wsConnection.bufferCount) {
wsConnection.bufferCount = 0;
}
if (wsConnection.bufferCount < 2) {
const blob = new Blob([event.data], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const linkElement = document.createElement('a');
linkElement.href = url;
linkElement.download = `buffer-${wsConnection.bufferCount + 1}.mp4`;
linkElement.textContent = `下载第 ${wsConnection.bufferCount + 1} 个 buffer`;
linkElement.target = '_blank';
const debug = document.getElementById('debug');
const time = new Date().toLocaleTimeString();
const newLine = document.createElement('div');
newLine.textContent = `[${time}] 第 ${wsConnection.bufferCount + 1} 个 buffer 大小: ${event.data.byteLength} 字节 `;
newLine.appendChild(linkElement);
debug.insertBefore(newLine, debug.firstChild);
wsConnection.bufferCount++;
}
// 初始化缓存数组
if (!wsConnection.cachedBuffers) {
wsConnection.cachedBuffers = [];
}
// 添加到缓存
wsConnection.cachedBuffers.push(new Uint8Array(event.data));
log(`已缓存 ${wsConnection.cachedBuffers.length} 个数据包`);
// 当累积到指定数量的数据包时,合并并添加到 buffer
if (wsConnection.cachedBuffers.length >= bufferMergeCount) {
// 计算总长度
const totalLength = wsConnection.cachedBuffers.reduce((acc, curr) => acc + curr.byteLength, 0);
// 创建合并后的 buffer
const mergedBuffer = new Uint8Array(totalLength);
let offset = 0;
// 合并所有缓存的数据
for (const buffer of wsConnection.cachedBuffers) {
mergedBuffer.set(buffer, offset);
offset += buffer.byteLength;
}
log(`合并 ${wsConnection.cachedBuffers.length} 个数据包,总大小: ${totalLength} 字节`);
// 清空缓存
wsConnection.cachedBuffers = [];
// 添加到 MSE
await msePlayer.appendBuffer(mergedBuffer);
}
} catch (error) {
const errorMessage = error.message || '未知错误';
log(`处理数据失败: ${errorMessage}`);
// 只有在发生致命错误时才关闭连接
if (msePlayer.hasError) {
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
}
};
wsConnection.onclose = (event) => {
const reason = event.reason || '未知原因';
log(`WebSocket 连接已关闭: ${reason} (code: ${event.code})`);
if (msePlayer && !msePlayer.isDestroyed) {
msePlayer.destroy();
}
};
wsConnection.onerror = (error) => {
const errorMessage = error.message || '未知错误';
log(`WebSocket 错误: ${errorMessage}`);
if (msePlayer && !msePlayer.isDestroyed) {
msePlayer.destroy();
}
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
};
} catch (error) {
const errorMessage = error.message || '未知错误';
log(`WebSocket 初始化失败: ${errorMessage}`);
if (msePlayer && !msePlayer.isDestroyed) {
msePlayer.destroy();
}
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
}
}
// Handle local file
async function handleLocalFile(file) {
if (!file.name.toLowerCase().endsWith('.mp4') && !file.name.toLowerCase().endsWith('.fmp4')) {
alert('请选择 FMP4/MP4 文件');
return;
}
try {
log(`开始处理本地文件: ${file.name}`);
await initPlayer();
const buffer = await file.arrayBuffer();
log(`本地文件加载完成,大小: ${buffer.byteLength} 字节`);
await msePlayer.appendBuffer(buffer);
} catch (error) {
log(`处理本地文件失败: ${error.message}`);
}
}
// M3U8 handling
async function loadM3U8() {
const m3u8Url = document.getElementById('m3u8Url').value;
if (!m3u8Url) {
alert('请输入 M3U8 地址');
return;
}
try {
log(`开始加载 M3U8: ${m3u8Url}`);
const response = await fetch(m3u8Url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const content = await response.text();
// 显示 M3U8 内容
const m3u8ContentDiv = document.getElementById('m3u8Content');
m3u8ContentDiv.textContent = content;
const mp4Urls = parseM3U8(content, m3u8Url);
log(`解析到 ${mp4Urls.length} 个 MP4 文件`);
if (mp4Urls.length === 0) {
alert('未找到可播放的 MP4 文件');
return;
}
currentPlaylist = mp4Urls;
currentIndex = 0;
await initPlayer();
await loadNextSegment();
} catch (error) {
log(`加载 M3U8 文件失败: ${error.message}`);
alert('加载 M3U8 文件失败');
}
}
function parseM3U8(content, baseUrl) {
const lines = content.split('\n');
const mp4Urls = [];
let duration = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
// 解析 EXTINF 获取时长
if (line.startsWith('#EXTINF:')) {
duration = parseFloat(line.split(':')[1]);
continue;
}
// 跳过注释和空行
if (line === '' || line.startsWith('#')) {
continue;
}
// 处理 MP4 文件 URL
const url = line.startsWith('http') ? line : new URL(line, baseUrl).href;
mp4Urls.push({
url,
duration
});
log(`找到 MP4: ${url} (时长: ${duration}秒)`);
}
return mp4Urls;
}
async function loadNextSegment() {
if (currentIndex >= currentPlaylist.length) {
if (msePlayer.mediaSource.readyState === 'open') {
msePlayer.mediaSource.endOfStream();
log('已到达播放列表末尾');
}
return;
}
try {
const segment = currentPlaylist[currentIndex];
log(`加载视频片段 ${currentIndex + 1}/${currentPlaylist.length}`);
const response = await fetch(segment.url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const buffer = await response.arrayBuffer();
log(`视频片段 ${currentIndex + 1} 加载完成,大小: ${buffer.byteLength} 字节`);
await msePlayer.appendBuffer(buffer);
// 预加载下一个片段
if (currentIndex < currentPlaylist.length - 1 && msePlayer.pendingBuffers.length < 2) {
currentIndex++;
loadNextSegment();
}
} catch (error) {
log(`加载视频片段失败: ${error.message}`);
}
}
// FMP4 testing
async function testFMP4() {
const fmp4Url = document.getElementById('fmp4Url').value;
if (!fmp4Url) {
alert('请输入 FMP4 地址');
return;
}
try {
log(`开始测试 FMP4: ${fmp4Url}`);
await initPlayer();
const response = await fetch(fmp4Url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const buffer = await response.arrayBuffer();
log(`FMP4 文件加载完成,大小: ${buffer.byteLength} 字节`);
await msePlayer.appendBuffer(buffer);
} catch (error) {
log(`加载 FMP4 文件失败: ${error.message}`);
}
}
// Event listeners
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const video = document.getElementById('videoPlayer');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('drag-over');
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('drag-over');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleLocalFile(files[0]);
}
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleLocalFile(e.target.files[0]);
}
});
video.addEventListener('ended', () => {
log('视频播放结束');
});
video.addEventListener('playing', () => log('视频开始播放'));
video.addEventListener('pause', () => log('视频暂停'));
video.addEventListener('waiting', () => log('视频缓冲中'));
video.addEventListener('canplay', () => log('视频可以播放'));
video.addEventListener('loadedmetadata', () => log('视频元数据已加载'));
video.addEventListener('error', (e) => {
if (msePlayer && !msePlayer.isDestroyed) {
log(`视频错误: ${video.error ? video.error.message : '未知错误'}`);
msePlayer.handleError(video.error ? video.error.message : '未知错误');
}
if (wsConnection) {
wsConnection.close();
wsConnection = null;
}
});
</script>
</body>
</html>