2026-08-03 09:49:53 +08:00
|
|
|
|
const MEDIA_HEADER_SIZE = 26;
|
|
|
|
|
|
const MEDIA_MAGIC = [0x43, 0x4d, 0x56, 0x46]; // CMVF
|
|
|
|
|
|
const MEDIA_MIME_TYPES = {
|
|
|
|
|
|
1: 'video/mp4; codecs="avc1.42C01E"'
|
|
|
|
|
|
};
|
2025-08-21 14:11:58 +08:00
|
|
|
|
|
|
|
|
|
|
class WebSocketManager {
|
|
|
|
|
|
constructor(url, userId) {
|
|
|
|
|
|
this.url = url;
|
|
|
|
|
|
this.userId = userId;
|
|
|
|
|
|
this.ws = null;
|
|
|
|
|
|
this.events = {};
|
|
|
|
|
|
this.reconnectTimer = null;
|
|
|
|
|
|
this.heartbeatTimer = null;
|
|
|
|
|
|
this.isManualClose = false;
|
|
|
|
|
|
this.messageQueue = [];
|
2026-08-03 09:49:53 +08:00
|
|
|
|
this.processingMessages = false;
|
|
|
|
|
|
this.maxQueueSize = 100;
|
2025-08-21 14:11:58 +08:00
|
|
|
|
this.status = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
|
if (!window.WebSocket) {
|
|
|
|
|
|
console.error('浏览器不支持 WebSocket');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
this.ws = new WebSocket(`${this.url}?userId=${this.userId}`, [], { perMessageDeflate: true });
|
|
|
|
|
|
this.ws.binaryType = 'arraybuffer'; // 确保接收 ArrayBuffer
|
|
|
|
|
|
|
|
|
|
|
|
this.ws.onopen = () => {
|
|
|
|
|
|
console.log('WebSocket 已连接');
|
|
|
|
|
|
this.status = true;
|
|
|
|
|
|
this.startHeartbeat();
|
|
|
|
|
|
this.processMessageQueue();
|
2026-08-03 09:49:53 +08:00
|
|
|
|
this.dispatch('open');
|
2025-08-21 14:11:58 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-08-03 09:49:53 +08:00
|
|
|
|
this.ws.onmessage = async (event) => {
|
|
|
|
|
|
const data = event.data instanceof Blob ? await event.data.arrayBuffer() : event.data;
|
|
|
|
|
|
if (data instanceof ArrayBuffer && this.isMediaPacket(data)) {
|
|
|
|
|
|
this.dispatchMediaPacket(data);
|
|
|
|
|
|
return;
|
2025-08-21 14:11:58 +08:00
|
|
|
|
}
|
2026-08-03 09:49:53 +08:00
|
|
|
|
if (this.messageQueue.length >= this.maxQueueSize) {
|
|
|
|
|
|
console.warn('WebSocket控制消息队列已满,丢弃最旧消息');
|
|
|
|
|
|
this.messageQueue.shift();
|
|
|
|
|
|
}
|
|
|
|
|
|
this.messageQueue.push(data);
|
|
|
|
|
|
this.processMessageQueue();
|
2025-08-21 14:11:58 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this.ws.onclose = () => {
|
|
|
|
|
|
console.log('WebSocket 断开');
|
|
|
|
|
|
this.status = false;
|
|
|
|
|
|
this.stopHeartbeat();
|
2026-08-03 09:49:53 +08:00
|
|
|
|
this.dispatch('close');
|
2025-08-21 14:11:58 +08:00
|
|
|
|
if (!this.isManualClose) {
|
|
|
|
|
|
this.reconnect();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
this.ws.onerror = (err) => {
|
|
|
|
|
|
console.error('WebSocket 错误:', err);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-03 09:49:53 +08:00
|
|
|
|
isMediaPacket(buffer) {
|
|
|
|
|
|
if (buffer.byteLength < MEDIA_HEADER_SIZE) return false;
|
|
|
|
|
|
const bytes = new Uint8Array(buffer, 0, MEDIA_MAGIC.length);
|
|
|
|
|
|
return MEDIA_MAGIC.every((value, index) => bytes[index] === value);
|
|
|
|
|
|
}
|
2025-08-21 14:11:58 +08:00
|
|
|
|
|
2026-08-03 09:49:53 +08:00
|
|
|
|
dispatchMediaPacket(buffer) {
|
2025-08-21 14:11:58 +08:00
|
|
|
|
try {
|
2026-08-03 09:49:53 +08:00
|
|
|
|
const view = new DataView(buffer);
|
|
|
|
|
|
const version = view.getUint8(4);
|
|
|
|
|
|
const mediaType = view.getUint8(5);
|
|
|
|
|
|
const timestamp = view.getUint32(8) * 0x100000000 + view.getUint32(12);
|
|
|
|
|
|
const sequence = view.getUint32(16);
|
|
|
|
|
|
const channelLength = view.getUint16(20);
|
|
|
|
|
|
const payloadLength = view.getUint32(22);
|
|
|
|
|
|
const payloadOffset = MEDIA_HEADER_SIZE + channelLength;
|
|
|
|
|
|
if (version !== 1 || payloadOffset + payloadLength !== buffer.byteLength) {
|
|
|
|
|
|
throw new Error('媒体数据包头无效');
|
2025-08-21 14:11:58 +08:00
|
|
|
|
}
|
2026-08-03 09:49:53 +08:00
|
|
|
|
const channel = new TextDecoder().decode(new Uint8Array(buffer, MEDIA_HEADER_SIZE, channelLength));
|
|
|
|
|
|
const mimeType = MEDIA_MIME_TYPES[mediaType];
|
|
|
|
|
|
if (!mimeType) {
|
|
|
|
|
|
throw new Error(`不支持的媒体类型: ${mediaType}`);
|
2025-08-21 14:11:58 +08:00
|
|
|
|
}
|
2026-08-03 09:49:53 +08:00
|
|
|
|
this.dispatch(channel, {
|
|
|
|
|
|
media: true,
|
|
|
|
|
|
mimeType,
|
|
|
|
|
|
sequence,
|
|
|
|
|
|
timestamp,
|
|
|
|
|
|
data: buffer.slice(payloadOffset, payloadOffset + payloadLength)
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('WebSocket媒体包解析失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-08-21 14:11:58 +08:00
|
|
|
|
|
2026-08-03 09:49:53 +08:00
|
|
|
|
async processMessageQueue() {
|
|
|
|
|
|
if (this.processingMessages || !this.ws) return;
|
|
|
|
|
|
this.processingMessages = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
while (this.messageQueue.length > 0) {
|
|
|
|
|
|
const data = this.messageQueue.shift();
|
|
|
|
|
|
let message;
|
|
|
|
|
|
if (typeof data === 'string') {
|
|
|
|
|
|
message = JSON.parse(data);
|
|
|
|
|
|
} else if (data instanceof ArrayBuffer) {
|
|
|
|
|
|
const decompressedStream = new Blob([data])
|
|
|
|
|
|
.stream()
|
|
|
|
|
|
.pipeThrough(new DecompressionStream('gzip'));
|
|
|
|
|
|
const decompressedData = await new Response(decompressedStream).arrayBuffer();
|
|
|
|
|
|
message = JSON.parse(new TextDecoder().decode(decompressedData));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
throw new Error(`不支持的WebSocket数据类型: ${typeof data}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (message.channel) {
|
|
|
|
|
|
this.dispatch(message.channel, message.payload);
|
|
|
|
|
|
} else if (message.type) {
|
|
|
|
|
|
this.dispatch(message.type, message.payload ?? message.content ?? message);
|
|
|
|
|
|
}
|
2025-08-21 14:11:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
2026-08-03 09:49:53 +08:00
|
|
|
|
console.error('WebSocket消息解析失败:', err);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
this.processingMessages = false;
|
|
|
|
|
|
if (this.messageQueue.length > 0) {
|
|
|
|
|
|
queueMicrotask(() => this.processMessageQueue());
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dispatch(eventName, payload) {
|
|
|
|
|
|
const callbacks = this.events[eventName];
|
|
|
|
|
|
if (callbacks) {
|
|
|
|
|
|
callbacks.slice().forEach(callback => callback(payload));
|
2025-08-21 14:11:58 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
on(eventName, callback) {
|
|
|
|
|
|
if (!this.events[eventName]) {
|
|
|
|
|
|
this.events[eventName] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
this.events[eventName].push(callback);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
off(eventName, callback) {
|
|
|
|
|
|
if (this.events[eventName]) {
|
|
|
|
|
|
const index = this.events[eventName].indexOf(callback);
|
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
|
this.events[eventName].splice(index, 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
send(payload) {
|
|
|
|
|
|
if (this.ws && this.ws.readyState === 1) {
|
|
|
|
|
|
this.ws.send(JSON.stringify(payload));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.warn('WebSocket 未连接,消息发送失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
close() {
|
|
|
|
|
|
this.isManualClose = true;
|
|
|
|
|
|
this.status = false;
|
|
|
|
|
|
this.stopHeartbeat();
|
|
|
|
|
|
if (this.ws) {
|
|
|
|
|
|
this.ws.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
reconnect() {
|
|
|
|
|
|
clearTimeout(this.reconnectTimer);
|
|
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
|
|
|
|
console.log('尝试重新连接 WebSocket...');
|
|
|
|
|
|
this.connect();
|
|
|
|
|
|
}, 5000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
startHeartbeat() {
|
|
|
|
|
|
this.heartbeatTimer = setInterval(() => {
|
|
|
|
|
|
this.send({ type: 'heartbeat' });
|
|
|
|
|
|
}, 30000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
stopHeartbeat() {
|
|
|
|
|
|
clearInterval(this.heartbeatTimer);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
status() {
|
|
|
|
|
|
return this.status;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default {
|
|
|
|
|
|
install(app, options) {
|
|
|
|
|
|
const { url, userId } = options;
|
|
|
|
|
|
const wsManager = new WebSocketManager(url, userId);
|
|
|
|
|
|
wsManager.connect();
|
|
|
|
|
|
app.config.globalProperties.$ws = wsManager;
|
|
|
|
|
|
app.provide('ws', wsManager);
|
|
|
|
|
|
}
|
2026-07-28 16:26:07 +08:00
|
|
|
|
};
|