From 6a64f3c79592299086a657548e4714f9f777d3a8 Mon Sep 17 00:00:00 2001 From: John Smith Date: Sun, 13 Sep 2020 16:29:49 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E5=89=8D=E7=AB=AF=E7=9B=B4?= =?UTF-8?q?=E8=BF=9EB=E7=AB=99=E5=BC=B9=E5=B9=95=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/package.json | 1 + frontend/src/api/chat/ChatClientDirect.js | 329 ++++++++++++++++++++++ frontend/src/api/chat/ChatClientRelay.js | 5 +- frontend/src/api/chat/avatar.js | 19 ++ frontend/src/utils.js | 9 + frontend/src/views/Room.vue | 9 +- 6 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 frontend/src/api/chat/ChatClientDirect.js create mode 100644 frontend/src/api/chat/avatar.js diff --git a/frontend/package.json b/frontend/package.json index 78ee6fb..d5ad3f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,6 +13,7 @@ "downloadjs": "^1.4.7", "element-ui": "^2.9.1", "lodash": "^4.17.19", + "pako": "^1.0.11", "vue": "^2.6.10", "vue-i18n": "^8.11.2", "vue-router": "^3.0.6" diff --git a/frontend/src/api/chat/ChatClientDirect.js b/frontend/src/api/chat/ChatClientDirect.js new file mode 100644 index 0000000..ddb6dd2 --- /dev/null +++ b/frontend/src/api/chat/ChatClientDirect.js @@ -0,0 +1,329 @@ +import {inflate} from 'pako' +import {getUuid4Hex} from '@/utils' +import * as avatar from './avatar' + +const HEADER_SIZE = 16 + +// const WS_BODY_PROTOCOL_VERSION_NORMAL = 0 +// const WS_BODY_PROTOCOL_VERSION_INT = 1 // 用于心跳包 +const WS_BODY_PROTOCOL_VERSION_DEFLATE = 2 + +// const OP_HANDSHAKE = 0 +// const OP_HANDSHAKE_REPLY = 1 +const OP_HEARTBEAT = 2 +const OP_HEARTBEAT_REPLY = 3 +// const OP_SEND_MSG = 4 +const OP_SEND_MSG_REPLY = 5 +// const OP_DISCONNECT_REPLY = 6 +const OP_AUTH = 7 +const OP_AUTH_REPLY = 8 +// const OP_RAW = 9 +// const OP_PROTO_READY = 10 +// const OP_PROTO_FINISH = 11 +// const OP_CHANGE_ROOM = 12 +// const OP_CHANGE_ROOM_REPLY = 13 +// const OP_REGISTER = 14 +// const OP_REGISTER_REPLY = 15 +// const OP_UNREGISTER = 16 +// const OP_UNREGISTER_REPLY = 17 +// B站业务自定义OP +// const MinBusinessOp = 1000 +// const MaxBusinessOp = 10000 + +let textEncoder = new TextEncoder() +let textDecoder = new TextDecoder() + +export default class ChatClientDirect { + constructor (roomId) { + // 调用initRoom后初始化,如果失败,使用这里的默认值 + this.roomId = roomId + this.roomOwnerUid = 0 + this.hostServerList = [ + {host: "broadcastlv.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244} + ] + + this.onAddText = null + this.onAddGift = null + this.onAddMember = null + this.onAddSuperChat = null + this.onDelSuperChat = null + this.onUpdateTranslation = null + + this.websocket = null + this.retryCount = 0 + this.isDestroying = false + this.heartbeatTimerId = null + } + + async start () { + await this.initRoom() + this.wsConnect() + } + + stop () { + this.isDestroying = true + if (this.websocket) { + this.websocket.close() + } + } + + async initRoom () { + // TODO 请求后端 + } + + makePacket (data, operation) { + let body = textEncoder.encode(JSON.stringify(data)) + let header = new ArrayBuffer(HEADER_SIZE) + let headerView = new DataView(header) + headerView.setUint32(0, HEADER_SIZE + body.byteLength) // pack_len + headerView.setUint16(4, HEADER_SIZE) // raw_header_size + headerView.setUint16(6, 1) // ver + headerView.setUint32(8, operation) // operation + headerView.setUint32(12, 1) // seq_id + return new Blob([header, body]) + } + + sendAuth () { + let authParams = { + uid: 0, + roomid: this.roomId, + protover: 2, + platform: 'web', + clientver: '1.14.3', + type: 2 + } + this.websocket.send(this.makePacket(authParams, OP_AUTH)) + } + + wsConnect () { + if (this.isDestroying) { + return + } + let hostServer = this.hostServerList[this.retryCount % this.hostServerList.length] + const url = `wss://${hostServer.host}:${hostServer.wss_port}/sub` + this.websocket = new WebSocket(url) + this.websocket.binaryType = 'arraybuffer' + this.websocket.onopen = this.onWsOpen.bind(this) + this.websocket.onclose = this.onWsClose.bind(this) + this.websocket.onmessage = this.onWsMessage.bind(this) + this.heartbeatTimerId = window.setInterval(this.sendHeartbeat.bind(this), 10 * 1000) + } + + sendHeartbeat () { + this.websocket.send(this.makePacket({}, OP_HEARTBEAT)) + } + + onWsOpen () { + this.sendAuth() + } + + onWsClose () { + this.websocket = null + if (this.heartbeatTimerId) { + window.clearInterval(this.heartbeatTimerId) + this.heartbeatTimerId = null + } + if (this.isDestroying) { + return + } + window.console.log(`掉线重连中${++this.retryCount}`) + window.setTimeout(this.wsConnect.bind(this), 1000) + } + + onWsMessage (event) { + this.retryCount = 0 + if (!(event.data instanceof ArrayBuffer)) { + window.console.warn('未知的websocket消息:', event.data) + return + } + let data = new Uint8Array(event.data) + this.handlerMessage(data) + } + + handlerMessage (data) { + let offset = 0 + while (offset < data.byteLength) { + let dataView = new DataView(data.buffer, offset) + let packLen = dataView.getUint32(0) + // let rawHeaderSize = dataView.getUint16(4) + let ver = dataView.getUint16(6) + let operation = dataView.getUint32(8) + // let seqId = dataView.getUint32(12) + + switch (operation) { + case OP_HEARTBEAT_REPLY: { + // 人气值没用 + break + } + case OP_SEND_MSG_REPLY: { + let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE) + if (ver == WS_BODY_PROTOCOL_VERSION_DEFLATE) { + body = inflate(body) + this.handlerMessage(body) + } else { + try { + body = JSON.parse(textDecoder.decode(body)) + this.handlerCommand(body) + } catch (e) { + window.console.warn('body:', body) + throw e + } + } + break + } + case OP_AUTH_REPLY: { + this.sendHeartbeat() + break + } + default: { + let body = new Uint8Array(data.buffer, offset + HEADER_SIZE, packLen - HEADER_SIZE) + window.console.warn('未知包类型:operation=', operation, body) + break + } + } + + offset += packLen + } + } + + handlerCommand (command) { + if (command instanceof Array) { + for (let oneCommand of command) { + this.handlerCommand(oneCommand) + } + return + } + + let cmd = command.cmd || '' + let pos = cmd.indexOf(':') + if (pos != -1) { + cmd = cmd.substr(0, pos) + } + let handler = COMMAND_HANDLERS[cmd] + if (handler) { + handler.call(this, command) + } + } + + async onReceiveDanmaku (command) { + if (!this.onAddText) { + return + } + let info = command.info + + let roomId, medalLevel + if (info[3]) { + roomId = info[3][3] + medalLevel = info[3][0] + } else { + roomId = medalLevel = 0 + } + + let uid = info[2][0] + let isAdmin = info[2][2] + let privilegeType = info[7] + let authorType + if (uid === this.roomOwnerUid) { + authorType = 3 + } else if (isAdmin) { + authorType = 2 + } else if (privilegeType !== 0) { + authorType = 1 + } else { + authorType = 0 + } + + let urank = info[2][5] + let data = { + avatarUrl: await avatar.getAvatarUrl(uid), + timestamp: info[0][4] / 1000, + authorName: info[2][1], + authorType: authorType, + content: info[1], + privilegeType: privilegeType, + isGiftDanmaku: !!info[0][9], + authorLevel: info[4][0], + isNewbie: urank < 10000, + isMobileVerified: !!info[2][6], + medalLevel: roomId === this.roomId ? medalLevel : 0, + id: getUuid4Hex(), + translation: '' + } + this.onAddText(data) + } + + onReceiveGift (command) { + if (!this.onAddGift) { + return + } + let data = command.data + if (data.coin_type !== 'gold') { // 丢人 + return + } + + data = { + id: getUuid4Hex(), + avatarUrl: avatar.processAvatarUrl(data.face), + timestamp: data.timestamp, + authorName: data.uname, + totalCoin: data.total_coin, + giftName: data.giftName, + num: data.num + } + this.onAddGift(data) + } + + async onBuyGuard (command) { + if (!this.onAddMember) { + return + } + + let data = command.data + data = { + id: getUuid4Hex(), + avatarUrl: await avatar.getAvatarUrl(data.uid), + timestamp: data.start_time, + authorName: data.username, + privilegeType: data.guard_level + } + this.onAddMember(data) + } + + onSuperChat (command) { + if (!this.onAddSuperChat) { + return + } + + let data = command.data + data = { + id: data.id, + avatarUrl: avatar.processAvatarUrl(data.user_info.face), + timestamp: data.start_time, + authorName: data.user_info.uname, + price: data.price, + content: data.message, + translation: '' + } + this.onAddSuperChat(data) + } + + onSuperChatDelete (command) { + if (!this.onDelSuperChat) { + return + } + + let ids = [] + for (let id of command.data.ids) { + ids.push(id.toString()) + } + this.onDelSuperChat({ids}) + } +} + +const COMMAND_HANDLERS = { + DANMU_MSG: ChatClientDirect.prototype.onReceiveDanmaku, + SEND_GIFT: ChatClientDirect.prototype.onReceiveGift, + GUARD_BUY: ChatClientDirect.prototype.onBuyGuard, + SUPER_CHAT_MESSAGE: ChatClientDirect.prototype.onSuperChat, + SUPER_CHAT_MESSAGE_DELETE: ChatClientDirect.prototype.onSuperChatDelete +} diff --git a/frontend/src/api/chat/ChatClientRelay.js b/frontend/src/api/chat/ChatClientRelay.js index 2d03461..b16c726 100644 --- a/frontend/src/api/chat/ChatClientRelay.js +++ b/frontend/src/api/chat/ChatClientRelay.js @@ -37,6 +37,9 @@ export default class ChatClientRelay { } wsConnect () { + if (this.isDestroying) { + return + } const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws' // 开发时使用localhost:12450 const host = process.env.NODE_ENV === 'development' ? 'localhost:12450' : window.location.host @@ -77,7 +80,7 @@ export default class ChatClientRelay { return } window.console.log(`掉线重连中${++this.retryCount}`) - this.wsConnect() + window.setTimeout(this.wsConnect.bind(this), 1000) } onWsMessage (event) { diff --git a/frontend/src/api/chat/avatar.js b/frontend/src/api/chat/avatar.js new file mode 100644 index 0000000..a3407b5 --- /dev/null +++ b/frontend/src/api/chat/avatar.js @@ -0,0 +1,19 @@ +export const DEFAULT_AVATAR_URL = '//static.hdslb.com/images/member/noface.gif' + +export function processAvatarUrl (avatarUrl) { + // 去掉协议,兼容HTTP、HTTPS + let m = avatarUrl.match(/(?:https?:)?(.*)/) + if (m) { + avatarUrl = m[1] + } + // 缩小图片加快传输 + if (!avatarUrl.endsWith('noface.gif')) { + avatarUrl += '@48w_48h' + } + return avatarUrl +} + +export async function getAvatarUrl () { + // TODO 请求后端 + return DEFAULT_AVATAR_URL +} diff --git a/frontend/src/utils.js b/frontend/src/utils.js index a753a4f..0241387 100644 --- a/frontend/src/utils.js +++ b/frontend/src/utils.js @@ -32,3 +32,12 @@ export function getTimeTextHourMin (date) { let min = ('00' + date.getMinutes()).slice(-2) return `${hour}:${min}` } + +export function getUuid4Hex () { + let chars = [] + for (let i = 0; i < 32; i++) { + let char = Math.floor(Math.random() * 16).toString(16) + chars.push(char) + } + return chars.join('') +} diff --git a/frontend/src/views/Room.vue b/frontend/src/views/Room.vue index d285eaf..7a65001 100644 --- a/frontend/src/views/Room.vue +++ b/frontend/src/views/Room.vue @@ -5,7 +5,8 @@