mirror of
https://github.com/langhuihui/monibuca.git
synced 2026-05-09 22:01:20 +08:00
843 lines
27 KiB
HTML
843 lines
27 KiB
HTML
<!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> |