Repository: zsxsoft/danmu-client Branch: electron Commit: e8444b64899d Files: 24 Total size: 39.7 KB Directory structure: gitextract_7g7r0epy/ ├── .eslintignore ├── .eslintrc ├── .gitattributes ├── .gitignore ├── .travis.yml ├── app/ │ ├── index.html │ ├── index.js │ ├── lib/ │ │ ├── danmu/ │ │ │ ├── comment.js │ │ │ ├── commentframe.js │ │ │ ├── frame.js │ │ │ ├── index.js │ │ │ ├── player.js │ │ │ └── sprite.js │ │ ├── listener/ │ │ │ └── index.js │ │ └── utils/ │ │ └── index.js │ ├── main.js │ ├── package.json │ ├── panel.html │ └── panel.js ├── appveyor.yml ├── config.js ├── package.json ├── readme.md └── resources/ └── icon.icns ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintignore ================================================ /http/res/* config.js ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "babel-eslint", "parserOptions": { "sourceType": "module" }, "env": { "node": true, "browser": true }, "extends": "standard", "plugins": [ "import" ], "globals": { "coordinator": true, "windows": true } } ================================================ FILE: .gitattributes ================================================ # Auto detect text files and perform LF normalization * text=auto # Custom for Visual Studio *.cs diff=csharp # Standard to msysgit *.doc diff=astextplain *.DOC diff=astextplain *.docx diff=astextplain *.DOCX diff=astextplain *.dot diff=astextplain *.DOT diff=astextplain *.pdf diff=astextplain *.PDF diff=astextplain *.rtf diff=astextplain *.RTF diff=astextplain ================================================ FILE: .gitignore ================================================ /cache /builds /typings /out /dist # Logs logs *.log # Runtime data pids *.pid *.seed # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directory # Commenting this out is preferred by some people, see # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- node_modules # Users Environment Variables .lock-wscript # ========================= # Operating System Files # ========================= # OSX # ========================= .DS_Store .AppleDouble .LSOverride # Thumbnails ._* # Files that might appear on external disk .Spotlight-V100 .Trashes # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk # Windows # ========================= # Windows image file caches Thumbs.db ehthumbs.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk ================================================ FILE: .travis.yml ================================================ osx_image: xcode8.3 sudo: required dist: trusty language: node_js node_js: '8' env: global: - ELECTRON_CACHE=$HOME/.cache/electron - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder matrix: include: - os: osx - os: linux cache: directories: - node_modules - "$HOME/.cache/electron" - "$HOME/.cache/electron-builder" - "$HOME/.npm/_prebuilds" script: - npm run release ================================================ FILE: app/index.html ================================================ DANMU Client

大小和位置调整完毕后,按下回车或点击按钮开启弹幕。
实际位置即黑框所在位置。

版本号:
弹幕客户端制作:
GitHub:


按下F1,打开GitHub,获取最新的源代码及获得帮助。
按下F12,打开调试器。

================================================ FILE: app/index.js ================================================ 'use strict'; (() => { const electron = require('electron') const windows = electron.remote.getGlobal('windows') // const coordinator = electron.remote.getGlobal('coordinator'); const {shell} = electron const path = require('path') const fs = require('fs') const danmu = require('./lib/danmu') const listener = require('./lib/listener') const crypto = require('crypto') const packageJson = require('./package.json') let isStart = false let config = null try { config = eval(fs.readFileSync(path.resolve('config.js'), 'utf-8')) // eslint-disable-line no-eval } catch (e) { alert('你的config.js修改有误,解析出错:\n' + e.stack.toString()) windows.mainWindow.openDevTools({detach: true}) throw e } global.config = config function initFunction () { windows.mainWindow.setResizable(false) // Electron doesn't support both resizable and transparency windows.mainWindow.setIgnoreMouseEvents(true) document.getElementById('message').remove() document.querySelector('.border').remove() document.querySelector('body').style.background = 'transparent' listener.init(config) danmu.init(config, listener, document.getElementById('main-canvas')) isStart = true } function keydownFunction (e) { switch (e.keyCode) { case 13: if (!isStart) { initFunction() isStart = true } break case 116: e.preventDefault() break case 112: shell.openExternal(packageJson.homepage) break case 123: windows.mainWindow.webContents.openDevTools({detach: true}) break } } document.getElementById('client-version').innerHTML = packageJson.version document.getElementById('client-homepage').innerHTML = packageJson.homepage document.getElementById('client-author').innerHTML = packageJson.author document.title = 'DANMU Client - Client ID = ' + crypto.createHash('md5').update(Math.random().toString()).digest('hex') window.addEventListener('keydown', keydownFunction, true) })() ================================================ FILE: app/lib/danmu/comment.js ================================================ /* global config */ 'use strict' let utils = require('../utils') let Sprite = require('./sprite') const DW_TEXT = 1 const DW_IMAGE = 2 /** * 字幕对象,继承自Sprite对象 * @param param = {id, x, y, width, height, speed, text, lifeTime, color, font} */ class Comment extends Sprite { constructor (param) { super(param.id, param.x, param.y, param.width, param.height, param.speed, param.lifeTime) this.cache = null this.text = param.text || '' // 文字内容 this.lifeTime = param.lifeTime || config.display.comment.lifeTime this.color = param.color || config.display.comment.color this.font = param.font || config.display.comment.fontStyle } /** * 弹幕的绘制方法 */ draw (canvasContext) { canvasContext.fillStyle = this.color canvasContext.font = this.font if (!config.display.image) { canvasContext.shadowOffsetX = 1 canvasContext.shadowOffsetY = 1 canvasContext.shadowBlur = 1 canvasContext.fillText(this.text, this.x, this.y + this.height) return } // 以下为图文混排相关代码 if (this.cache === null) { this.cache = [] let lastIndex = 0 // 记录未写入文字的起始点 let nextPos = 0 let spaceWidth = canvasContext.measureText(' ').width // 得到字间距 utils.imageAnalyzer.regex = config.image.regex utils.imageAnalyzer.test((ret, imageObject) => { let text = this.text.substr(lastIndex, ret.index - lastIndex) this.cache.push({ type: DW_TEXT, position: nextPos, text: text }) nextPos += canvasContext.measureText(text).width + spaceWidth // 计算此时图片位置 this.cache.push({ type: DW_IMAGE, object: imageObject, position: nextPos, width: imageObject.width }) nextPos += imageObject.width + spaceWidth lastIndex = ret.index + ret[0].length // 更新未写入文字的起始点 }, this.text) this.cache.push({ type: DW_TEXT, position: nextPos, text: this.text.substr(lastIndex, this.text.length) }) } let x = this.x let y = this.y let height = this.height let actualHeight = y + height this.cache.forEach(function (val) { switch (val.type) { case DW_TEXT: canvasContext.shadowOffsetX = 1 canvasContext.shadowOffsetY = 1 canvasContext.shadowBlur = 1 canvasContext.fillText(val.text, x + val.position, actualHeight) break case DW_IMAGE: if (val.object.error) { return // 出错 } canvasContext.shadowBlur = 0 canvasContext.shadowOffsetX = 0 canvasContext.shadowOffsetY = 0 if (val.object.loaded) { canvasContext.drawImage(val.object.element, x + val.position, y + height / 10 + 1, val.width, height) // 绘制图片 // 10+1是一个修正偏移的魔法数字 val.object.loaded = true } else { utils.tryCatch(() => { canvasContext.drawImage(val.object.element, x + val.position, y + height / 10 + 1, val.width, height) // 绘制图片 }, () => { val.object.error = true }) } break } }) } /** * 更新弹幕的生命状态 */ updateLifeTime () { this.lifeTime-- // 每刷新一帧,存活时间-1 this.alive = (this.lifeTime >= 0) }; } module.exports = Comment ================================================ FILE: app/lib/danmu/commentframe.js ================================================ /* global config */ 'use strict' let Frame = require('./frame') let Comment = require('./comment') /** * 弹幕frame对象,继承字Frame对象 * @param width * @param height * @param canvasContext * @returns */ class CommentFrame extends Frame { constructor (width, height, canvasContext) { super(width, height, canvasContext) /** * 标志本帧所绘制的弹幕是可见还是隐藏 */ this.visible = true /** * 保存需要绘制的多层弹幕 */ this.layers = [] /** * 弹幕ID所对应的弹幕层 */ this.idMap = {} /** * 创建缓冲画布 */ this.bufCanvas = window.document.createElement('canvas') this.bufCanvas.width = width this.bufCanvas.height = height /** * 标识是否打开弹幕 */ this.danmuState = true } /** * 重设置本frame的宽和高,同时bufCanvas尺寸也随之修改 * @param width * @param height */ resize (width, height) { this.width = width this.height = height this.bufCanvas.width = width this.bufCanvas.height = height }; /** * @Override * 向Frame中添加弹幕对象. * 在本Frame中根据生成弹幕Sprite.这样当播放器进度seek的时候,重新根据弹幕信息重新new的Sprite对象的x位置是对的; * 如果在Playr中new好了,再根据时间add进来,当播放器往回seek的时候,加进来的是已存在的对象,这时对象的x可能是已经超出显示范围的负数了,不会被再次显示。 */ addSprite (info) { if (!this.danmuState) return let that = this let style = info.style || config.display.comment.animationStyle let color = info.color || config.display.comment.fontColor let font = info.font || config.display.comment.fontStyle let lifeTime = info.lifeTime || config.display.comment.lifeTime let height = info.height || config.display.comment.height let text = info.text // 文字的宽度【注意,ctx.measureText(text).width得到文字宽度是基于ctx当前的font的,如果取得width后改变了ctx.font很可能width与实际文字宽度不匹配】 this.bufCanvas.getContext('2d').font = font let width = this.bufCanvas.getContext('2d').measureText(text).width let result = generateY(style, height, 0) // 计算弹幕位置,从第0层弹幕开始 let y = result.y let index = result.index let x = generateX(style, width) this.layers[index].push(new Comment({ x: x, y: y, width: width, height: height, speed: generateSpeed(style, x, y, lifeTime), text: text, lifeTime: lifeTime, color: color, font: font, id: info.id })) this.idMap[info.id] = { layer: index, index: this.layers[index].length - 1 } /** * 确定弹幕的速度 * @param style 弹幕类型 * @returns speed{} */ function generateSpeed (style, x, y, lifeTime) { if (style === 'scroll') { return { x: -(x + width) / lifeTime, // -(移动距离+文本宽度)/(移动时间*帧数) y: 0 } } else if (style === 'reversescroll') { return { x: (width + that.width) / lifeTime, y: 0 } } else if (style === 'staticdown' || style === 'staticup') { return { x: 0, y: 0 } } // 这里一大串if和elseif实在是不够优雅 // 不过目前就这四个的话懒得再去抽象成接口 // 这里用桥接模式的话应该很合适呢 } /** * 确定弹幕的X坐标 * @param style 弹幕类型 * @param textWidth 该弹幕的文字内容宽度 * @returns x */ function generateX (style, textWidth) { if (style === 'scroll') { return that.width } else if (style === 'reversescroll') { return 0 } else if (style === 'staticdown' || style === 'staticup') { return (that.width - textWidth) / 2 } } /** * 检查是否与当前Frame中其他弹幕重叠 * @param y 本弹幕y坐标 * @param size 本弹幕高度 * @param index 当前排序所在的弹幕层 * @returns {Boolean} true表示有重叠 */ function checkDanmu (y, size, index) { let currentLayerDanmus = that.layers[index] // 取得当前弹幕层的所有danmus for (let i = 0; i < currentLayerDanmus.length; i++) { let danmu = currentLayerDanmus[i] if (y + size > danmu.y && y < danmu.y + danmu.height) { // 如果有重叠 return true } } return false // 没有重叠 } /** * 确定弹幕的y坐标 * @param style 弹幕类型 * @param size 该弹幕的高(字号) * @param index 当前排序所在的弹幕层 * @returns {} {'y坐标':y,'所在弹幕层号index':index} */ function generateY (style, size, index) { if (index > 20) { return { 'y': 0, 'index': index - 1 } } // 超过20层就不显示了 while (!that.layers[index]) { // 如果当前弹幕层还不存在 // 增加弹幕层 that.layers.push([]) } let y = 0 if (style === 'scroll' || style === 'reversescroll') { // 滚动字幕尽量向顶部聚集,但不重叠 while (y < that.height - size) { if (checkDanmu(y, size, index)) { y++ } else { // 找到合适位置 return { 'y': y, 'index': index } } } } else if (style === 'staticdown') { // 底部字幕尽量向底部聚集,但不重叠 y = that.height - height - 8 // 从底部-文字高度-底部边距的位置开始往上排,默认底部边距是8. while (y > 0) { if (checkDanmu(y, size, index)) { y-- } else { // 找到合适位置 return { 'y': y, 'index': index } } } } else if (style === 'staticup') { y = 0 + height + 8 // 注释参见staticdown while (y > 0) { if (checkDanmu(y, size, index)) { y++ } else { // 找到合适位置 return { 'y': y, 'index': index } } } } // 没有合适位置,再次调用本方法 return generateY(style, size, index + 1) } }; /** * 向Frame中添加自定义弹幕对象.默认渲染在现有层的最后一层即最上层. * @param className 自定义弹幕类 */ addCustomSprite (info) { if (!this.danmuState) return let script = window.document.createElement('script') let className = 'danmuClass__' + new Date().getTime().toString() + '_' + Math.floor((Math.random() * 100000)).toString() let sourceCode = 'var ' + className + ' = ((function() { ' + info.sourceCode + '})()); ' script.innerHTML = sourceCode window.document.head.appendChild(script) let customSprite = new (window[className])(info) // 高度、宽度等不受系统管理 while (!this.layers[this.layers.length - 1]) { // 如果当前弹幕层还不存在 this.layers.push([]) // 增加弹幕层 } let layer = this.layers.length - 1 this.layers[layer].push(customSprite) this.idMap[info.id] = { layer: layer, index: this.layers[layer].length - 1 } }; /** * @Override * 对本帧进行分层渲染 */ render () { let bufCanvasCtx = this.bufCanvas.getContext('2d') this.ctx.clearRect(0, 0, this.width, this.height) // 清空结果画布 bufCanvasCtx.clearRect(0, 0, this.width, this.height) // 清空buffer画布 // 渲染各层精灵到buffer画布上 for (let i = 0; i < this.layers.length; i++) { for (let j = 0; j < this.layers[i].length; j++) { this.layers[i][j].draw(bufCanvasCtx) } } // 往主图层上绘制buffer图层 this.ctx.drawImage(this.bufCanvas, 0, 0) }; /** * 如果播放器seek了,清空所有弹幕 */ clearDanmu () { for (let i = 0; i < this.layers.length; i++) { delete this.layers[i] // 删除相应对象 } Object.keys(this.idMap, key => delete this.idMap[key]) this.idMap = {} this.layers = [] }; /** * 停止显示弹幕,停止创建弹幕 */ stopDanmu () { this.clearDanmu() this.danmuState = false }; /** * 重新打开弹幕功能 */ restartDanmu () { this.danmuState = true }; /** * @Override * 更新CommentFrame中弹幕Sprite的状态 */ updateSprite () { for (let i = 0; i < this.layers.length; i++) { for (let j = 0; j < this.layers[i].length; j++) { // 更新位置 this.layers[i][j].move() // 更新生命状态 this.layers[i][j].updateLifeTime() } } }; /** * @Override * 清除已经死亡的Sprite */ clearSprite () { for (let i = 0; i < this.layers.length; i++) { for (let j = 0; j < this.layers[i].length; j++) { if (!this.layers[i][j].alive) { delete this.idMap[this.layers[i][j].id] delete this.layers[i][j] // 删除相应对象 this.layers[i] = this.layers[i].slice(0, j).concat(this.layers[i].slice(j + 1, this.layers[i].length)) // 清除数组中该位置 for (let l = j; l < this.layers[i].length; l++) { this.idMap[this.layers[i][l].id].index-- } } } } }; /** * @Override * 删除精灵元素 * @param sprite */ deleteSprites (indexs) { indexs.forEach(index => { if (this.idMap[index]) { if (this.layers[this.idMap[index].layer]) { if (this.layers[this.idMap[index].layer][this.idMap[index].index]) { this.layers[this.idMap[index].layer][this.idMap[index].index].remove() } } } }) }; } module.exports = CommentFrame ================================================ FILE: app/lib/danmu/frame.js ================================================ 'use strict' /** * 帧对象 * 原理就是每到一定时间就清除canvas,然后调用当前帧里的所有的元素的draw()方法。将所有动画元素按照新的配置重画,从而生成动画。 * 之后程序无需关心元素的重画,只需要调整元素属性即可。 */ class Frame { constructor (width, height, canvasContext) { /** * 帧的宽和高 */ this.width = width this.height = height /** * 记录绘制Frame的定时器id */ this.renderTimer = null /** * 本帧所要绘制的精灵元素 */ this.sprites = [] /** * 保存本帧相关的canvas标签的context */ this.ctx = canvasContext /** * 记录FPS */ this.fps = 0 } begin () { if (this.renderTimer !== null) { return // 防止重复启动 } let that = this; (function animate () { that.updateSprite() // 更新Sprite that.clearSprite() // 清除无效Sprite that.render() that.fps = that.countFps() // 计算FPS that.renderTimer = window.requestAnimationFrame(animate, that) })() }; /** * 渲染本帧【可根据需要在子对象中复写此方法】 */ render () { this.ctx.clearRect(0, 0, this.width, this.height) for (let i = 0; i < this.sprites.length; i++) { this.sprites[i].draw(this.ctx) } }; /** * 停止动画 */ stop () { if (this.renderTimer === null) { return } window.cancelAnimationFrame(this.renderTimer) this.renderTimer = null }; /** * 添加精灵元素 * @param sprite */ addSprite (sprite) { this.sprites.push(sprite) }; /** * 删除精灵元素 * @param sprite */ deleteSprites (indexs) { let that = this indexs.forEach(function (index) { if (that.sprites[index]) { that.sprites[index].remove() that.sprites.splice(index, 1) } }) }; /** * 更新本frame下所有Sprite的位置 */ updateSprite () { this.sprites.forEach(sprite => { sprite.move() }) }; /** * 清除超出显示范围的精灵元素 */ clearSprite () { this.sprites.forEach((sprite, index) => { if (sprite.x > this.width || this.sprites[index].y > this.height || sprite.x + sprite.width < 0 || sprite.y + sprite.height < 0) { delete this.sprites[index] // 删除相应对象 this.sprites = this.sprites.slice(0, index).concat(this.sprites.slice(index + 1, this.sprites.length)) // 清除数组中该位置 } }) }; } /** * 计算FPS */ Frame.prototype.countFps = (function () { let lastLoop = (new Date()).getMilliseconds() let count = 1 let fps = 0 return function () { let currentLoop = (new Date()).getMilliseconds() if (lastLoop > currentLoop) { fps = count count = 1 } else { count += 1 } lastLoop = currentLoop return fps } })() module.exports = Frame ================================================ FILE: app/lib/danmu/index.js ================================================ 'use strict' const coordinator = require('electron').remote.getGlobal('coordinator') const Player = require('./player') const player = new Player() // 监听得到弹幕的事件 coordinator.on('gotDanmu', data => { player.parseDanmus(data) player.controlDanmu('update') }) // 监听操作相关事件 coordinator.on('danmuControl', data => { window.danmuControl[data].call() }) // 删除弹幕事件 coordinator.on('deleteDanmu', data => { player.deleteDanmus(data.ids) }) function send (param) { player.parseDanmus([param]) player.controlDanmu('update') }; function init (config, listener, object) { if (config.image.preload) { config.image.whitelist.map(url => { let img = window.document.createElement('img') img.width = 0 img.height = 0 img.src = url window.document.body.appendChild(img) }) } player.setup(object, 'canvas-danmu') player.controlDanmu('play') window.setInterval(function () { coordinator.emit('fps', player.frame.fps) }, 1000) window.console.log('弹幕初始化完成!') }; module.exports = { init: init, send: send, example: function () { let i = 1 let id = 10000 setInterval(function () { send({ text: '[IMG WIDTH=24]danmu-24.png[/IMG]测试[IMG WIDTH=24]danmu-24.png[/IMG]Hello World[IMG WIDTH=24]danmu-24.png[/IMG]', color: 'rgb(' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ')', lifeTime: 500, textStyle: 'normal bold ' + i + 'em 微软雅黑', height: i * 10, id: id }) i++ if (i > 5) i = 1 }, 10) }, stop: function () { player.frame.stopDanmu() }, start: function () { player.frame.restartDanmu() }, clear: function () { player.frame.clearDanmu() } } // 给window添加私货方便调试 window.danmuControl = module.exports ================================================ FILE: app/lib/danmu/player.js ================================================ 'use strict' let path = require('path') let fs = require('fs') let url = require('url') let CommentFrame = require('./commentframe') /** * 已经审核过的安全Url缓存 * @type {Array} */ let safeUrl = [] /** * 检测URL是否合法 * @param string content * @return bool */ function checkUrlValidate (content) { let regex = config.image.regex regex.lastIndex = 0 let ret = null while ((ret = regex.exec(content)) !== null) { let unbelieveUrl = ret[2] if (safeUrl[unbelieveUrl]) continue // 加载缓存 let parsedUrl = url.parse(unbelieveUrl) if (parsedUrl.protocol) { // 如果是网络协议就检查白名单 return (config.image.whitelist.indexOf(unbelieveUrl) >= 0) } let safePath = path.join('/', unbelieveUrl) let filePath = path.resolve('./' + safePath) let unsafePath = path.resolve(unbelieveUrl) if (filePath !== unsafePath) { return false // 文件在上级目录或其他目录,判定为非法 } if (!fs.existsSync(unsafePath)) { // 文件不存在,判定为非法 return false } } return true } /** * 弹幕播放器 * @constructor * @param {Integer} insertElement * @param {DOMElement} danmuConfig */ class Player { constructor (insertElement, danmuConfig) { this.insertElement = null // 绘制canvas相关的组件 this.canvas = null this.frame = null // 存放解析好的弹幕内容 this.danmus = [] this.config = danmuConfig } /** * 初始化方法 */ setup (insertObject, elementId) { this.insertElement = insertObject let w = this.insertElement.offsetWidth // 控件的宽 let h = this.insertElement.offsetHeight // 控件的高 this.canvas = this.addCanvasElement(elementId, w, h) this.insertElement.parentNode.insertBefore(this.canvas, this.insertElement) let canvasContext = this.canvas.getContext('2d') this.frame = new CommentFrame(w, h, canvasContext) } /** * 控制弹幕 * @param action */ controlDanmu (action) { if (action === 'play') { this.frame.begin() } else if (action === 'stop') { this.frame.stop() this.frame.clearDanmu() this.frame.render() } else if (action === 'update') { this.addDanmu() } } /** * 创建canvas元素 */ addCanvasElement (elementId, width, height) { let e = window.document.createElement('canvas') e.id = elementId e.style.position = 'absolute' e.style.zIndex = '1000000' e.style.display = 'block' e.width = width e.height = height return e } /** * 将从服务器取得所有弹幕的内容,进行解析,放入this.danmus */ parseDanmus (jsonResp) { let nowTime = (new Date()).valueOf() this.danmus = [] jsonResp.forEach(danmu => { // 先检测图片弹幕 if (config.display.image) { if (!checkUrlValidate(danmu.text)) return } danmu.font = danmu.textStyle danmu.lifeTime4TimeStamp = danmu.lifeTime * 1000 / 60 danmu.addTime = nowTime danmu.height = parseInt(danmu.height) danmu.lifeTime = parseInt(danmu.lifeTime) this.danmus.push(danmu) }) } /** * 弹幕精灵添加 */ addDanmu () { this.danmus.forEach(info => { if (info.style === 'custom') { this.frame.addCustomSprite(info) } else { this.frame.addSprite(info) } }) } /** * 弹幕精灵删除 */ deleteDanmus (ids) { this.frame.deleteSprites(ids) } /** * 显示/隐藏弹幕的处理函数 */ toggleDanmu () { if (this.frame.visible) { // 弹幕可见 this.frame.clearDanmu() // 情况当前所有待渲染弹幕 this.frame.render() // 重绘一帧空的屏幕 this.frame.stop() // 停止Frame this.frame.visible = false // 设置弹幕标记为不可见 } else { // 弹幕隐藏 this.frame.begin() this.frame.visible = true } } } module.exports = Player ================================================ FILE: app/lib/danmu/sprite.js ================================================ 'use strict' /** * 精灵对象 * 所有的动画元素都必须继承自此对象,继承之后自动拥有move方法、速度属性、删除自身方法. * 每个动画元素都必须拥有一个自己的特殊的draw()方法的实现,这个方法用来在渲染每一帧的时候指定自己如何呈现在每一帧(frame.js)上 * @param id * @param x 精灵相对与画布的位置 * @param y * @param width 精灵的宽 * @param height * @param speed * @param lifeTime * @param alive * @returns {Sprite} */ class Sprite { constructor (id, x, y, width, height, speed, lifeTime, alive) { this.id = id || 0 this.x = x || 0 this.y = y || 0 this.width = width || 0 this.height = height || 0 this.lifeTime = lifeTime || 0 this.alive = alive || true this.children = [] /** * 精灵移动速度 */ this.speed = speed || { x: 0, y: 0 } } draw () { } move () { this.x += this.speed.x this.y += this.speed.y if (typeof this.children !== 'undefined') { for (let i = 0; i < this.children.length; i++) { this.children[i].speed = this.speed this.children[i].move() } } } /** * 向此精灵添加一个子精灵 */ appendChild (sprite) { this.children.push(sprite) } /** * 渲染子精灵 */ drawChildren () { this.children.forEach(function (child) { child.draw() }) } /** * 删除自身 */ remove () { this.lifeTime = 0 this.alive = false }; } module.exports = Sprite ================================================ FILE: app/lib/listener/index.js ================================================ /* global config */ 'use strict' const packageJson = require('../../package.json') const player = require('../danmu/index') const Socket = require('socket.io-client') const coordinator = require('electron').remote.getGlobal('coordinator') let io let serverRandomNumber = null module.exports = { init: function () { io = Socket(config.socket.url) io.heartbeatTimeout = config.socket.heartbeat realInit(config) } } function realInit () { let initCount = 0 io.on('init', () => { initCount++ io.emit('password', { password: config.socket.password, room: config.socket.room, info: { version: packageJson.version } }) if (initCount > 1) { window.console.log('连接密码错误') } }) io.on('connected', data => { initCount = 0 window.console.log('已连接上弹幕服务器(' + data.version + ')') if (serverRandomNumber !== data.randomNumber) { if (serverRandomNumber !== null) { window.console.log('服务器似乎已重启,将清空弹幕池。') // 如果断线(服务器重启?)了,必须清理原有弹幕,否则会导致ID池不匹配 } player.stop() player.clear() player.start() serverRandomNumber = data.randomNumber } }) io.on('disconnect', () => { window.console.warn('与服务器的连接中断') }) io.on('danmu', data => { window.console.log('得到' + data.data.length + '条弹幕') coordinator.emit('gotDanmu', data.data) }) io.on('delete', data => { window.console.log('删除' + data.ids.length + '条弹幕') coordinator.emit('deleteDanmu', data) }) }; ================================================ FILE: app/lib/utils/index.js ================================================ 'use strict' const url = require('url') const path = require('path') class ImageCache { constructor () { this.cache = [] this.regex = null } test (callback, text) { if (!this.regex) return // Initialize here let ret = null this.regex.lastIndex = 0 // Analyze text while ((ret = this.regex.exec(text)) !== null) { let src = ret[2] let imageWidth = parseInt(ret[1]) let imageObject = this.getImage(src) if (imageObject === null) { imageObject = this.buildCache(src, imageWidth) } callback(ret, imageObject) } this.regex.lastIndex = 0 } getImage (src) { return this.cache[src] || null } buildCache (src, width) { let parsedUrl = url.parse(src) let image = window.document.createElement('img') image.src = parsedUrl.protocol ? src : path.resolve('./', './' + src) image.width = width image.onerror = function () { window.console.error('Cannot load ' + src) this.cache[src].error = true } this.cache[src] = { error: false, element: image, width: width } return this.cache[src] } } let images = new ImageCache() function tryCatch (fn, fnCatch) { try { fn() } catch (e) { fnCatch(e) } } module.exports = { imageAnalyzer: images, tryCatch } ================================================ FILE: app/main.js ================================================ const electron = require('electron') const path = require('path') const {app, BrowserWindow} = electron global.coordinator = new (require('events').EventEmitter)() global.windows = {} coordinator.on('exit', () => { app.exit(0) }) app.on('window-all-closed', () => { app.exit(0) }) app.on('ready', () => { windows.panelWindow = new BrowserWindow({ width: 390, height: 150, resizable: false, icon: `${__dirname}/danmu.png` }) windows.panelWindow.loadURL(`file://${__dirname}/panel.html`) windows.panelWindow.on('closed', () => { // app.exit(); }) windows.panelWindow.setMenu(null) windows.mainWindow = new BrowserWindow({ transparent: true, frame: false, toolbar: false, resizable: true, title: 'DANMU Client', alwaysOnTop: true, icon: path.join(__dirname, '/danmu.png') }) windows.mainWindow.loadURL(`file://${__dirname}/index.html`) windows.mainWindow.on('closed', () => { coordinator.emit('exit') }) windows.mainWindow.setMenu(null) }) ================================================ FILE: app/package.json ================================================ { "name": "danmu-client", "version": "1.1.0-dev", "description": "Danmu client", "main": "./main.js", "author": "zsx ", "license": "MIT", "homepage": "https://github.com/zsxsoft/danmu-client", "dependencies": { "socket.io-client": "^2.0.3", "windows-caption-color": "0.0.6" } } ================================================ FILE: app/panel.html ================================================ DANMU Panel
================================================ FILE: app/panel.js ================================================ (function () { const electron = require('electron') const windows = electron.remote.getGlobal('windows') const coordinator = electron.remote.getGlobal('coordinator') const controlButtons = Array.from(window.document.querySelectorAll('.btn-control')) // I think querySelectorAll's api is terrible. let countQuitValue = 0 let isShow = true function controlButtonClick () { coordinator.emit(this.getAttribute('data-top'), this.getAttribute('data-param')) } coordinator.on('fps', fps => { if (!isShow) return document.title = 'FPS: ' + fps }) window.addEventListener('beforeunload', e => { // Hide but not exit // We cannot call a function that in a unregistered window. e.returnValue = 'false' windows.panelWindow.hide() isShow = false }) window.addEventListener('keydown', e => { if (e.keyCode === 123) { // F12 windows.panelWindow.webContents.openDevTools({ detach: true }) } }, true) document.querySelector('#btn-quit').addEventListener('click', () => { if (countQuitValue === 1) { coordinator.emit('exit') } else { setTimeout(() => { document.querySelector('#btn-quit').innerText = '退出程序' countQuitValue = 0 }, 5000) this.innerText = '再按一次' countQuitValue = 1 } return false }) controlButtons.forEach(item => { item.addEventListener('click', controlButtonClick) }) require('windows-caption-color').get((err, ret) => { if (!err) { window.document.body.style.background = 'rgba(' + ret.reg.r + ', ' + ret.reg.g + ', ' + ret.reg.b + ', ' + ret.reg.a + ')' } }) })() ================================================ FILE: appveyor.yml ================================================ environment: nodejs_version: "8" cache: - node_modules - '%APPDATA%\npm-cache' - '%USERPROFILE%\.electron' install: - ps: Install-Product node $env:nodejs_version - npm install test_script: - node --version - npm --version build_script: - npm run release shallow_clone: true clone_depth: 1 ================================================ FILE: config.js ================================================ ({ socket: { url: "http://127.0.0.1:3000", password: "", room: "unlimited", heartbeat: 3000 }, display: { comment: { animationStyle: "scroll", fontStyle: "normal bold 5em 微软雅黑", fontColor: "rgb(255, 255, 255)", lifeTime: 240, height: 50 }, image: true }, image: { regex: /\[IMG WIDTH=(\d+)\](.+?)\[\/IMG\]/ig, whitelist: [ "https://www.baidu.com/img/bd_logo1.png", "http://www.baidu.com/img/bd_logo1.png", ] } }); ================================================ FILE: package.json ================================================ { "name": "danmu-client", "version": "1.1.0", "description": "Danmu client", "main": "app/main.js", "scripts": { "start": "electron .", "build": "electron-builder", "release": "build" }, "build": { "productName": "danmu-client", "appId": "com.zsxsoft.danmu.client", "compression": "maximum", "extraFiles": [ "config.js" ], "mac": { "category": "public.app-category.utilities", "target": [ "zip" ] }, "win": { "target": [ "7z" ] }, "linux": { "target": [ "zip" ] }, "directories": { "buildResources": "resources" } }, "repository": { "type": "git", "url": "https://github.com/zsxsoft/danmu-client.git" }, "keywords": [ "danmu", "danmaku" ], "author": "zsx ", "license": "MIT", "bugs": { "url": "https://github.com/zsxsoft/danmu-client/issues" }, "homepage": "https://github.com/zsxsoft/danmu-client", "dependencies": { "socket.io-client": "^2.0.3", "windows-caption-color": "0.0.6" }, "devDependencies": { "7zip-bin": "^2.2.3", "babel-eslint": "^7.2.3", "electron": "^1.7.5", "electron-builder": "^19.22.1", "eslint": "^4.5.0", "eslint-config-standard": "^10.2.1", "eslint-plugin-import": "^2.7.0", "eslint-plugin-node": "^5.1.1", "eslint-plugin-promise": "^3.5.0", "eslint-plugin-standard": "^3.0.1", "node-gyp": "^3.6.2" } } ================================================ FILE: readme.md ================================================ danmu-client ========== [![Github All Releases](https://img.shields.io/github/downloads/zsxsoft/danmu-client/total.svg)](https://github.com/zsxsoft/danmu-client/releases) [![David deps](https://david-dm.org/zsxsoft/danmu-client.svg)](https://david-dm.org/zsxsoft/danmu-client) 这是一个独立的弹幕客户端,其服务端项目见[danmu-server](https://github.com/zsxsoft/danmu-server)。屏幕截图见[Release](https://github.com/zsxsoft/danmu-client/releases)。 **欲使用此项目,服务端需要使用对应的版本。[已发布的客户端](https://github.com/zsxsoft/danmu-client/releases)均已写明对应的服务端版本号,开发分支内的客户端版本仅对应开发分支的服务端。** ## 功能特色 - 以``WebSocket``作为通讯协议,用``Canvas``作为弹幕的画布; - 可在桌面任何位置显示弹幕,可与其他程序(如PowerPoint、视频播放器等)共同工作; - 窗口置于最前,完全透明可穿透,能与其他程序正常交互; - 提供紧急清空弹幕池、停止接收弹幕等功能,可删除单条弹幕; - 支持图片弹幕。 ### 适用场景 - 数十人至千人集会(如学校活动、电影放映会)的实时互动 - 大型活动实时公告信息显示 - 欲在桌面显示实时吐槽 ## 最低系统要求 Windows 7 / macOS 10.9 (x64) / Ubuntu 12.04 / Fedora 21 / Debian 8 ## 使用预编译版本 1. 打开[Release](https://github.com/zsxsoft/danmu-client/releases)下载已经编译好的程序包并解压到某目录。 2. 双击目录下的``danmu-client``,启动成功。 ## 从源码启动 1. 下载并安装[Nodejs](https://nodejs.org),并检查[node-gyp](https://github.com/nodejs/node-gyp)的依赖环境。 2. ``npm install`` 3. ``npm start`` ## 打包发布 ``npm run build`` ## 配置说明 根目录``config.js``下有配置,以下是说明 socket: { url: "弹幕服务器开启的IP与端口(如使用反代,需要确认反代支持WebSocket连接)", password: "弹幕服务器连接密码", room: "选择连接弹幕服务器的某间房间", heartbeat: 心跳包发送间隔 }, display: { comment: { animationStyle: "默认弹幕样式(支持scroll、reversescroll、staticdown、staticup)", fontStyle: "默认字体样式", fontColor: "默认颜色", lifeTime: 每条弹幕的基本存活时间, height: 每条弹幕占据高度 }, image: 图片弹幕开关 }, image: { regex: 图片判断正则,勿动 whitelist: [ "图片弹幕允许加载的网络图片白名单。", "不支持通配符,必须一条一条手动添加。", "请确认图片可以正常被打开。" ], preload: 网络图片是否预读缓存 } ## 图片弹幕 打开图片弹幕开关后,弹幕内含相关内容的将被解析为图片。图片必须可以正常打开,调用代码如:``[IMG WIDTH=24]danmu-24.png[/IMG]``。格式:``[IMG WIDTH=图片宽度]图片地址(支持HTTP)[/IMG]`` 为了保证安全与稳定,图片弹幕有防火墙机制。只有在弹幕程序目录及子目录下存在的图片才可被加载。引用网络图片,必须手动修改``config.js``添加白名单规则。如果被过滤,则程序不会有任何提示,该弹幕也不会被显示。 ## 自定义弹幕 需要在服务器打开相应开关后,才允许使用自定义弹幕功能。自定义弹幕必须返回一个函数(或类),继承自``lib/danmu/sprite.js``中的``Sprite``,并需要实现``updateLifeTime``方法和``draw``方法,有``alive``属性。__为确保效率,自定义弹幕未加入错误捕捉。一旦函数出错,则弹幕系统停止接受新弹幕。__ 示例代码如下(生成一个颜色随机、在屏幕上晃来晃去的玩意): ### 最新版示例代码 ```javascript return (() => { 'use strict'; const Sprite = require('./lib/danmu/sprite'); let canvasWidth = 0; let canvasHeight = 0; class Comment extends Sprite { constructor(param) { super(param.id, param.x, param.y, param.width, param.height, param.speed, param.lifeTime); this.text = param.text || ""; //文字内容 this.lifeTime = param.lifeTime || config.display.comment.lifeTime; this.font = param.font || config.display.comment.fontStyle; } draw(canvasContext) { if (canvasWidth === 0) canvasWidth = canvasContext.canvas.width; if (canvasHeight === 0) canvasHeight = canvasContext.canvas.height; canvasContext.fillStyle = `rgb(${parseInt(Math.random() * 255)}, ${parseInt(Math.random() * 255)}, ${parseInt(Math.random() * 255)})`; canvasContext.font = this.font; canvasContext.fillText(this.text, parseInt(Math.random() * canvasWidth), parseInt(Math.random() * canvasHeight)); } updateLifeTime() { this.lifeTime--; //每刷新一帧,存活时间-1 this.alive = (this.lifeTime >= 0); }; } return Comment; })(); ``` ## 开源协议 The MIT License (MIT) ## 博文 [弹幕服务器及搭配之透明弹幕客户端研究结题报告](https://blog.zsxsoft.com/post/15) [弹幕服务器及搭配之透明弹幕客户端研究中期报告](https://blog.zsxsoft.com/post/14) [弹幕服务器及搭配之透明弹幕客户端研究开题报告](https://blog.zsxsoft.com/post/13) ## 开发者 zsx - https://www.zsxsoft.com / 博客 - https://blog.zsxsoft.com ## 感谢 [DDPlayer](https://github.com/dpy1123/ddplayer) by dpy1123