diff --git a/frontend/.eslintignore b/frontend/.eslintignore index 96be2c3..e684bb4 100644 --- a/frontend/.eslintignore +++ b/frontend/.eslintignore @@ -1,2 +1,3 @@ brotli_decode.js pronunciation/dict*.js +blcsdk.js diff --git a/frontend/src/blcsdk.js b/frontend/src/blcsdk.js new file mode 100644 index 0000000..22f017a --- /dev/null +++ b/frontend/src/blcsdk.js @@ -0,0 +1,312 @@ +/** @module blcsdk */ + +(function(root, factory) { + if (typeof define === 'function' && define.amd) { + define([], factory) + } else { + root.blcsdk = factory() + } +}(typeof self !== 'undefined' ? self : this, function() { + const exports = {} + + const VERSION = '1.0.0' + /** + * 取SDK版本 + * @returns {string} "1.0.0" + */ + exports.getVersion = () => VERSION + + // 初始化消息的Promise {promise, resolve, reject} + let initPromise = null + // 初始化消息,包含版本、配置等信息 + let initMsg = null + + /** + * 消息处理器 + * @type {MsgHandler} + */ + let msgHandler = null + /** + * 用户设置的消息处理器 + * @type {MsgHandler} + */ + let rawMsgHandler = null + + /** + * @typedef InitOptions + * @property {boolean} noMsgDelay 去掉消息延迟,但会导致消息不平滑 + */ + + /** + * 初始化SDK + * + * 在调用除了setMsgHandler以外的其他接口之前必须先调用这个 + */ + async function init( + /** @type {?InitOptions} */ + {noMsgDelay = false} = {} + ) { + if (initPromise) { + throw new Error('Cannot call init() again') + } + // initPromise = Promise.withResolvers() + initPromise = {} + initPromise.promise = new Promise((resolve, reject) => { + initPromise.resolve = resolve + initPromise.reject = reject + }) + + if (window.parent === window) { + initPromise.reject(new Error('No parent window')) + return initPromise.promise + } + + msgHandler = noMsgDelay ? new SdkMsgHandler() : new SmoothedSdkMsgHandler() + window.addEventListener('message', onWindowMessage) + + // 连接blivechat + blcSendMsg('blcTemplateConnect') + setTimeout(() => initPromise.reject(new Error('Timed out waiting for blcInit message')), 10 * 1000) + + // 等待初始化消息 + initMsg = await initPromise.promise + console.debug('blcsdk initialized, init_msg=', initMsg) + } + exports.init = init + + /** + * 设置消息处理器 + * @param {?MsgHandler} handler 消息处理器 + */ + function setMsgHandler(handler) { + rawMsgHandler = handler + } + exports.setMsgHandler = setMsgHandler + + /** + * 取blivechat前端版本 + * @returns {string} "v1.10.0-dev" + */ + function getBlcVersion() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return initMsg.blcVersion + } + exports.getBlcVersion = getBlcVersion + + /** + * 取blivechat前端用的SDK版本。是父窗口用的版本,不是这个包的getVersion返回值" + * @returns {string} "1.0.0" + */ + function getBlcSdkVersion() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return initMsg.sdkVersion + } + exports.getBlcSdkVersion = getBlcSdkVersion + + /** + * @typedef Config + * @property {boolean} showGiftName 显示礼物名 + * @property {boolean} mergeSimilarDanmaku 合并相似弹幕 + * @property {boolean} mergeGift 合并礼物 + * @property {number} maxNumber 最大弹幕数 + */ + + /** + * 取blivechat前端房间部分配置 + * @returns {Config} + */ + function getConfig() { + if (!initMsg) { + throw new Error('Please call init() first') + } + return Object.freeze(initMsg.config) + } + exports.getConfig = getConfig + + function blcSendMsg(type, data = null) { + if (window.parent === window) { + return + } + let msg = { type, data } + window.parent.postMessage(msg, '*') + } + + function onWindowMessage(event) { + if (event.source !== window.parent) { + return + } + + let { type, data } = event.data + switch (type) { + case 'blcInit': + initPromise.resolve(data) + break + case 'blcAddMsg': + msgHandler.addMsg(data) + break + case 'blcDelMsgs': + msgHandler.delMsgs(data.ids) + break + case 'blcUpdateMsg': + msgHandler.updateMsg(data.id, data.newValuesObj) + break + } + } + + /** 模板消息处理器接口 */ + class MsgHandler { + /** + * 添加消息 + * @param {Object} msg + */ + addMsg(msg) {} + + /** + * 删除消息,主要用于撤回醒目留言 + * @param {string[]} ids 要删除的消息ID + */ + delMsgs(ids) {} + + /** + * 更新消息字段,主要用于更新翻译结果 + * @param {string} id 要更新的消息ID + * @param {Object} newValuesObj 字段和对应的新值 + */ + updateMsg(id, newValuesObj) {} + } + exports.MsgHandler = MsgHandler + + class SdkMsgHandler extends MsgHandler { + addMsg(msg) { this._callRawHandler('addMsg', msg) } + delMsgs(ids) { this._callRawHandler('delMsgs', ids) } + updateMsg(id, newValuesObj) { this._callRawHandler('updateMsg', id, newValuesObj) } + _callRawHandler(...args) { doCallRawHandler(...args) } + } + + function doCallRawHandler(funcName, ...args) { + if (!rawMsgHandler) { + return + } + try { + let func = rawMsgHandler[funcName] + return func.call(rawMsgHandler, ...args) + } catch (e) { + console.error(e) + } + } + + // 发送消息时间间隔范围 + const MSG_MIN_INTERVAL = 80 + const MSG_MAX_INTERVAL = 1000 + + class SmoothedSdkMsgHandler extends SdkMsgHandler { + constructor() { + super() + // 消息队列 + this._queue = [] + // 消费消息队列的定时器ID + this._emitSmoothedMsgTimerId = null + // 最近进队列的时间间隔,用来估计下次进队列的时间 + this._enqueueIntervals = [] + // 上次进队列的时间 + this._lastEnqueueTime = null + // 估计的下次进队列时间间隔 + this._estimatedEnqueueInterval = null + + this._boundEmitSmoothedMsgs = this._emitSmoothedMsgs.bind(this) + } + + _callRawHandler(funcName, ...args) { + let msg = {funcName, args} + this._enqueueMsg(msg) + } + + _enqueueMsg(msg) { + // 估计进队列时间间隔 + if (!this._lastEnqueueTime) { + this._lastEnqueueTime = new Date() + } else { + let curTime = new Date() + let interval = curTime - this._lastEnqueueTime + // 真实的进队列时间间隔模式大概是这样:2500, 300, 300, 300, 2500, 300, ... + // B站消息有缓冲,会一次发多条消息。这里把波峰视为发送了一次真实的WS消息,所以要过滤掉间隔太小的 + if (interval > 1000 || this._enqueueIntervals.length < 5) { + this._enqueueIntervals.push(interval) + if (this._enqueueIntervals.length > 5) { + this._enqueueIntervals.splice(0, this._enqueueIntervals.length - 5) + } + // 这边估计得尽量大,只要不太早把消息缓冲发完就是平滑的。有MESSAGE_MAX_INTERVAL保底,不会让消息延迟太大 + // 其实可以用单调队列求最大值,偷懒不写了 + this._estimatedEnqueueInterval = Math.max(...this._enqueueIntervals) + } + // 上次入队时间还是要设置,否则会太早把消息缓冲发完,然后较长时间没有新消息 + this._lastEnqueueTime = curTime + } + + this._queue.push(msg) + + if (!this._emitSmoothedMsgTimerId) { + this._emitSmoothedMsgTimerId = setTimeout(this._boundEmitSmoothedMsgs) + } + } + + _emitSmoothedMsgs() { + this._emitSmoothedMsgTimerId = null + if (this._queue.length <= 0) { + return + } + + // 估计的下次进队列剩余时间 + let estimatedNextEnqueueRemainTime = 10 * 1000 + if (this._estimatedEnqueueInterval) { + estimatedNextEnqueueRemainTime = Math.max(this._lastEnqueueTime - new Date() + this._estimatedEnqueueInterval, 1) + } + // 计算发送的消息数,保证在下次进队列之前发完 + // 下次进队列之前应该发多少条消息 + let shouldEmitNum = Math.max(this._queue.length, 0) + // 下次进队列之前最多能发多少次 + let maxCanEmitCount = estimatedNextEnqueueRemainTime / MSG_MIN_INTERVAL + // 这次发多少条消息 + let numToEmit + if (shouldEmitNum < maxCanEmitCount) { + // 队列中消息数很少,每次发1条也能发完 + numToEmit = 1 + } else { + // 每次发1条以上,保证按最快速度能发完 + numToEmit = Math.ceil(shouldEmitNum / maxCanEmitCount) + } + + // 发消息 + let msgs = this._queue.splice(0, numToEmit) + for (let msg of msgs) { + doCallRawHandler(msg.funcName, ...msg.args) + } + + if (this._queue.length <= 0) { + return + } + // 消息没发完,计算下次发消息时间 + let sleepTime + if (numToEmit === 1) { + // 队列中消息数很少,随便定个[MESSAGE_MIN_INTERVAL, MESSAGE_MAX_INTERVAL]的时间 + sleepTime = estimatedNextEnqueueRemainTime / this._queue.length + sleepTime *= 0.5 + Math.random() + if (sleepTime > MSG_MAX_INTERVAL) { + sleepTime = MSG_MAX_INTERVAL + } else if (sleepTime < MSG_MIN_INTERVAL) { + sleepTime = MSG_MIN_INTERVAL + } + } else { + // 按最快速度发 + sleepTime = MSG_MIN_INTERVAL + } + this._emitSmoothedMsgTimerId = window.setTimeout(this._boundEmitSmoothedMsgs, sleepTime) + } + } + + return exports +})) diff --git a/frontend/src/views/Room.vue b/frontend/src/views/Room.vue index 3a5c66a..1c21c17 100644 --- a/frontend/src/views/Room.vue +++ b/frontend/src/views/Room.vue @@ -16,23 +16,62 @@ import * as chatModels from '@/api/chat/models' import ChatRenderer from '@/components/ChatRenderer' import * as constants from '@/components/ChatRenderer/constants' +class DefaultRenderer { + constructor(rendererVm) { + this.addMessage = rendererVm.addMessage + this.delMessages = rendererVm.delMessages + this.updateMessage = rendererVm.updateMessage + this.mergeSimilarText = rendererVm.mergeSimilarText + this.mergeSimilarGift = rendererVm.mergeSimilarGift + } + + destroy() { + let dummyFunc = () => {} + this.addMessage = dummyFunc + this.delMessages = dummyFunc + this.updateMessage = dummyFunc + this.mergeSimilarText = dummyFunc + this.mergeSimilarGift = dummyFunc + } +} + +const BLC_SDK_VERSION = '1.0.0' + class CustomTemplateRenderer { - constructor(templateIframe) { - this.templateIframe = templateIframe + constructor(templateIframe, config) { + this._templateIframe = templateIframe + this._config = config + + this._enabledSendMessageToTemplate = (type, data) => { + let msg = { type, data } + templateIframe.contentWindow.postMessage(msg, '*') + } + this._sendMessageToTemplate = () => {} + + this._boundOnWindowMessage = this._onWindowMessage.bind(this) + window.addEventListener('message', this._boundOnWindowMessage) + } + + destroy() { + window.removeEventListener('message', this._boundOnWindowMessage) + + let dummyFunc = () => {} + this._enabledSendMessageToTemplate = dummyFunc + this._sendMessageToTemplate = dummyFunc } addMessage(message) { - this.sendMessageToTemplate('blcAddMsg', message) + this._sendMessageToTemplate('blcAddMsg', message) } delMessages(ids) { let data = { ids } - this.sendMessageToTemplate('blcDelMsgs', data) + this._sendMessageToTemplate('blcDelMsgs', data) } updateMessage(id, newValuesObj) { let data = { id, newValuesObj } - this.sendMessageToTemplate('blcUpdateMsg', data) + this._sendMessageToTemplate('blcUpdateMsg', data) } mergeSimilarText() { @@ -43,9 +82,31 @@ class CustomTemplateRenderer { return false } - sendMessageToTemplate(cmd, data) { - let msg = { cmd, data } - this.templateIframe.contentWindow.postMessage(msg, '*') + _onWindowMessage(event) { + if (event.source !== this._templateIframe.contentWindow) { + return + } + + let { type } = event.data + switch (type) { + case 'blcTemplateConnect': { + this._sendMessageToTemplate = this._enabledSendMessageToTemplate + + // 发送初始化消息 + let initData = { + blcVersion: process.env.APP_VERSION, + sdkVersion: BLC_SDK_VERSION, + config: { + showGiftName: this._config.showGiftName, + mergeSimilarDanmaku: this._config.mergeSimilarDanmaku, + mergeGift: this._config.mergeGift, + maxNumber: this._config.maxNumber, + } + } + this._sendMessageToTemplate('blcInit', initData) + break + } + } } } @@ -78,7 +139,7 @@ export default { textEmoticons: [], // 官方的文本表情(后端配置的) pronunciationConverter: null, - customStyleElement, // 仅用于样式生成器中预览样式 + customStyleElement, // 仅用于样式生成器中预览样式和使用自定义模板时 presetCssLinkElement: null, renderer: null, @@ -124,7 +185,11 @@ export default { } }, mounted() { - this.renderer = !this.useCustomTemplate ? this.$refs.renderer : new CustomTemplateRenderer(this.$refs.templateIframe) + if (this.useCustomTemplate) { + this.renderer = new CustomTemplateRenderer(this.$refs.templateIframe, this.config) + } else { + this.renderer = new DefaultRenderer(this.$refs.renderer) + } if (document.visibilityState === 'visible') { if (this.roomKeyValue === null) { @@ -152,6 +217,10 @@ export default { if (this.presetCssLinkElement) { document.head.removeChild(this.presetCssLinkElement) } + + if (this.renderer) { + this.renderer.destroy() + } }, methods: { onVisibilityChange() {