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
==========
[](https://github.com/zsxsoft/danmu-client/releases)
[](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