[
  {
    "path": ".eslintignore",
    "content": "/http/res/*\nconfig.js"
  },
  {
    "path": ".eslintrc",
    "content": "{\n  \"root\": true,\n  \"parser\": \"babel-eslint\",\n  \"parserOptions\": {\n    \"sourceType\": \"module\"\n  },\n  \"env\": {\n    \"node\": true,\n    \"browser\": true\n  },\n  \"extends\": \"standard\",\n  \"plugins\": [\n    \"import\"\n  ],\n  \"globals\": {\n    \"coordinator\": true,\n    \"windows\": true\n  }\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "# Auto detect text files and perform LF normalization\n* text=auto\n\n# Custom for Visual Studio\n*.cs     diff=csharp\n\n# Standard to msysgit\n*.doc\t diff=astextplain\n*.DOC\t diff=astextplain\n*.docx diff=astextplain\n*.DOCX diff=astextplain\n*.dot  diff=astextplain\n*.DOT  diff=astextplain\n*.pdf  diff=astextplain\n*.PDF\t diff=astextplain\n*.rtf\t diff=astextplain\n*.RTF\t diff=astextplain\n"
  },
  {
    "path": ".gitignore",
    "content": "/cache\n/builds\n/typings\n/out\n/dist\n\n# Logs\nlogs\n*.log\n\n# Runtime data\npids\n*.pid\n*.seed\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Compiled binary addons (http://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directory\n# Commenting this out is preferred by some people, see\n# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git-\nnode_modules\n\n# Users Environment Variables\n.lock-wscript\n\n# =========================\n# Operating System Files\n# =========================\n\n# OSX\n# =========================\n\n.DS_Store\n.AppleDouble\n.LSOverride\n\n# Thumbnails\n._*\n\n# Files that might appear on external disk\n.Spotlight-V100\n.Trashes\n\n# Directories potentially created on remote AFP share\n.AppleDB\n.AppleDesktop\nNetwork Trash Folder\nTemporary Items\n.apdisk\n\n# Windows\n# =========================\n\n# Windows image file caches\nThumbs.db\nehthumbs.db\n\n# Folder config file\nDesktop.ini\n\n# Recycle Bin used on file shares\n$RECYCLE.BIN/\n\n# Windows Installer files\n*.cab\n*.msi\n*.msm\n*.msp\n\n# Windows shortcuts\n*.lnk\n\n"
  },
  {
    "path": ".travis.yml",
    "content": "osx_image: xcode8.3\nsudo: required\ndist: trusty\nlanguage: node_js\nnode_js: '8'\nenv:\n  global:\n  - ELECTRON_CACHE=$HOME/.cache/electron\n  - ELECTRON_BUILDER_CACHE=$HOME/.cache/electron-builder\nmatrix:\n  include:\n  - os: osx\n  - os: linux\ncache:\n  directories:\n  - node_modules\n  - \"$HOME/.cache/electron\"\n  - \"$HOME/.cache/electron-builder\"\n  - \"$HOME/.npm/_prebuilds\"\n\nscript:\n- npm run release\n"
  },
  {
    "path": "app/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n    <meta charset=\"UTF-8\">\n    <title>DANMU Client</title>\n    <style type=\"text/css\">\n\tcanvas{\n\t\t/**\n\t\t * In Electron, if an dragable object covered the border, \n\t\t * then the window will not resizable.\n\t\t */\n\t\theight: calc(100% - 2px);\n\t\twidth: calc(100% - 2px);\n\t\ttop: 1px;\n\t\tleft: 1px;\n\t\tposition: absolute;\n\t}\n    \n    #message {\n        color: white;\n        font-size: 12px;\n    }\n    \n    .border {\n        border: 3px solid red;\n        height: calc(100% - 6px);\n        width: calc(100% - 6px);\n        top: 0;\n        position: absolute;\n        left: 0;\n    }\n    \n    html {\n        overflow: hidden;\n    }\n    </style>\n</head>\n\n<body style=\"background: black\">\n    <div class=\"border\"></div>\n    <p id=\"message\">\n        大小和位置调整完毕后，按下回车或点击按钮开启弹幕。\n        <br/> 实际位置即黑框所在位置。\n        <br/>\n        <br/> 版本号：<span id=\"client-version\"></span>\n        <br/> 弹幕客户端制作：<span id=\"client-author\"></span>\n        <br/> GitHub: <span id=\"client-homepage\"></span>\n        <br/>\n        <br/>\n        <br/> 按下F1，打开GitHub，获取最新的源代码及获得帮助。\n        <br/> 按下F12，打开调试器。\n    </p>\n    <canvas id=\"main-canvas\" style=\"-webkit-app-region: drag\"></canvas>\n</body>\n<script src=\"index.js\"></script>\n\n</html>\n"
  },
  {
    "path": "app/index.js",
    "content": "'use strict';\n(() => {\n  const electron = require('electron')\n  const windows = electron.remote.getGlobal('windows')\n  //    const coordinator = electron.remote.getGlobal('coordinator');\n  const {shell} = electron\n  const path = require('path')\n  const fs = require('fs')\n  const danmu = require('./lib/danmu')\n  const listener = require('./lib/listener')\n\n  const crypto = require('crypto')\n  const packageJson = require('./package.json')\n  let isStart = false\n\n  let config = null\n  try {\n    config = eval(fs.readFileSync(path.resolve('config.js'), 'utf-8')) // eslint-disable-line no-eval\n  } catch (e) {\n    alert('你的config.js修改有误，解析出错：\\n' + e.stack.toString())\n    windows.mainWindow.openDevTools({detach: true})\n    throw e\n  }\n\n  global.config = config\n\n  function initFunction () {\n    windows.mainWindow.setResizable(false) // Electron doesn't support both resizable and transparency\n    windows.mainWindow.setIgnoreMouseEvents(true)\n\n    document.getElementById('message').remove()\n    document.querySelector('.border').remove()\n    document.querySelector('body').style.background = 'transparent'\n\n    listener.init(config)\n    danmu.init(config, listener, document.getElementById('main-canvas'))\n\n    isStart = true\n  }\n\n  function keydownFunction (e) {\n    switch (e.keyCode) {\n      case 13:\n        if (!isStart) {\n          initFunction()\n          isStart = true\n        }\n        break\n      case 116:\n        e.preventDefault()\n        break\n      case 112:\n        shell.openExternal(packageJson.homepage)\n        break\n      case 123:\n        windows.mainWindow.webContents.openDevTools({detach: true})\n        break\n    }\n  }\n\n  document.getElementById('client-version').innerHTML = packageJson.version\n  document.getElementById('client-homepage').innerHTML = packageJson.homepage\n  document.getElementById('client-author').innerHTML = packageJson.author\n  document.title = 'DANMU Client - Client ID = ' + crypto.createHash('md5').update(Math.random().toString()).digest('hex')\n  window.addEventListener('keydown', keydownFunction, true)\n})()\n"
  },
  {
    "path": "app/lib/danmu/comment.js",
    "content": "/* global config */\n'use strict'\n\nlet utils = require('../utils')\nlet Sprite = require('./sprite')\nconst DW_TEXT = 1\nconst DW_IMAGE = 2\n/**\n * 字幕对象，继承自Sprite对象\n * @param param = {id, x, y, width, height, speed, text, lifeTime, color, font}\n */\nclass Comment extends Sprite {\n  constructor (param) {\n    super(param.id, param.x, param.y, param.width, param.height, param.speed, param.lifeTime)\n    this.cache = null\n    this.text = param.text || '' // 文字内容\n    this.lifeTime = param.lifeTime || config.display.comment.lifeTime\n    this.color = param.color || config.display.comment.color\n    this.font = param.font || config.display.comment.fontStyle\n  }\n\n  /**\n     * 弹幕的绘制方法\n     */\n  draw (canvasContext) {\n    canvasContext.fillStyle = this.color\n    canvasContext.font = this.font\n    if (!config.display.image) {\n      canvasContext.shadowOffsetX = 1\n      canvasContext.shadowOffsetY = 1\n      canvasContext.shadowBlur = 1\n      canvasContext.fillText(this.text, this.x, this.y + this.height)\n      return\n    }\n\n    // 以下为图文混排相关代码\n    if (this.cache === null) {\n      this.cache = []\n      let lastIndex = 0 // 记录未写入文字的起始点\n      let nextPos = 0\n      let spaceWidth = canvasContext.measureText(' ').width // 得到字间距\n      utils.imageAnalyzer.regex = config.image.regex\n\n      utils.imageAnalyzer.test((ret, imageObject) => {\n        let text = this.text.substr(lastIndex, ret.index - lastIndex)\n        this.cache.push({\n          type: DW_TEXT,\n          position: nextPos,\n          text: text\n        })\n        nextPos += canvasContext.measureText(text).width + spaceWidth // 计算此时图片位置\n        this.cache.push({\n          type: DW_IMAGE,\n          object: imageObject,\n          position: nextPos,\n          width: imageObject.width\n        })\n        nextPos += imageObject.width + spaceWidth\n        lastIndex = ret.index + ret[0].length // 更新未写入文字的起始点\n      }, this.text)\n      this.cache.push({\n        type: DW_TEXT,\n        position: nextPos,\n        text: this.text.substr(lastIndex, this.text.length)\n      })\n    }\n\n    let x = this.x\n    let y = this.y\n    let height = this.height\n    let actualHeight = y + height\n    this.cache.forEach(function (val) {\n      switch (val.type) {\n        case DW_TEXT:\n          canvasContext.shadowOffsetX = 1\n          canvasContext.shadowOffsetY = 1\n          canvasContext.shadowBlur = 1\n          canvasContext.fillText(val.text, x + val.position, actualHeight)\n          break\n        case DW_IMAGE:\n          if (val.object.error) {\n            return // 出错\n          }\n          canvasContext.shadowBlur = 0\n          canvasContext.shadowOffsetX = 0\n          canvasContext.shadowOffsetY = 0\n          if (val.object.loaded) {\n            canvasContext.drawImage(val.object.element, x + val.position, y + height / 10 + 1, val.width, height) // 绘制图片\n                        // 10+1是一个修正偏移的魔法数字\n            val.object.loaded = true\n          } else {\n            utils.tryCatch(() => {\n              canvasContext.drawImage(val.object.element, x + val.position, y + height / 10 + 1, val.width, height) // 绘制图片\n            }, () => {\n              val.object.error = true\n            })\n          }\n\n          break\n      }\n    })\n  }\n  /**\n     * 更新弹幕的生命状态\n     */\n  updateLifeTime () {\n    this.lifeTime-- // 每刷新一帧，存活时间-1\n    this.alive = (this.lifeTime >= 0)\n  };\n}\n\nmodule.exports = Comment\n"
  },
  {
    "path": "app/lib/danmu/commentframe.js",
    "content": "/* global config */\n'use strict'\nlet Frame = require('./frame')\nlet Comment = require('./comment')\n\n/**\n * 弹幕frame对象，继承字Frame对象\n * @param width\n * @param height\n * @param canvasContext\n * @returns\n */\nclass CommentFrame extends Frame {\n  constructor (width, height, canvasContext) {\n    super(width, height, canvasContext)\n\n    /**\n         * 标志本帧所绘制的弹幕是可见还是隐藏\n         */\n    this.visible = true\n    /**\n         * 保存需要绘制的多层弹幕\n         */\n    this.layers = []\n    /**\n         * 弹幕ID所对应的弹幕层\n         */\n    this.idMap = {}\n    /**\n         * 创建缓冲画布\n         */\n    this.bufCanvas = window.document.createElement('canvas')\n    this.bufCanvas.width = width\n    this.bufCanvas.height = height\n    /**\n         * 标识是否打开弹幕\n         */\n    this.danmuState = true\n  }\n\n  /**\n     * 重设置本frame的宽和高，同时bufCanvas尺寸也随之修改\n     * @param width\n     * @param height\n     */\n  resize (width, height) {\n    this.width = width\n    this.height = height\n\n    this.bufCanvas.width = width\n    this.bufCanvas.height = height\n  };\n\n  /**\n     * @Override\n     * 向Frame中添加弹幕对象.\n     * 在本Frame中根据生成弹幕Sprite.这样当播放器进度seek的时候，重新根据弹幕信息重新new的Sprite对象的x位置是对的；\n     * 如果在Playr中new好了，再根据时间add进来，当播放器往回seek的时候，加进来的是已存在的对象，这时对象的x可能是已经超出显示范围的负数了，不会被再次显示。\n     */\n  addSprite (info) {\n    if (!this.danmuState) return\n\n    let that = this\n\n    let style = info.style || config.display.comment.animationStyle\n    let color = info.color || config.display.comment.fontColor\n    let font = info.font || config.display.comment.fontStyle\n    let lifeTime = info.lifeTime || config.display.comment.lifeTime\n    let height = info.height || config.display.comment.height\n    let text = info.text\n\n    // 文字的宽度【注意，ctx.measureText(text).width得到文字宽度是基于ctx当前的font的，如果取得width后改变了ctx.font很可能width与实际文字宽度不匹配】\n    this.bufCanvas.getContext('2d').font = font\n    let width = this.bufCanvas.getContext('2d').measureText(text).width\n\n    let result = generateY(style, height, 0) // 计算弹幕位置，从第0层弹幕开始\n    let y = result.y\n    let index = result.index\n\n    let x = generateX(style, width)\n\n    this.layers[index].push(new Comment({\n      x: x,\n      y: y,\n      width: width,\n      height: height,\n      speed: generateSpeed(style, x, y, lifeTime),\n      text: text,\n      lifeTime: lifeTime,\n      color: color,\n      font: font,\n      id: info.id\n    }))\n    this.idMap[info.id] = {\n      layer: index,\n      index: this.layers[index].length - 1\n    }\n\n    /**\n         * 确定弹幕的速度\n         * @param style 弹幕类型\n         * @returns speed{}\n         */\n    function generateSpeed (style, x, y, lifeTime) {\n      if (style === 'scroll') {\n        return {\n          x: -(x + width) / lifeTime, // -(移动距离+文本宽度)/(移动时间*帧数)\n          y: 0\n        }\n      } else if (style === 'reversescroll') {\n        return {\n          x: (width + that.width) / lifeTime,\n          y: 0\n        }\n      } else if (style === 'staticdown' || style === 'staticup') {\n        return {\n          x: 0,\n          y: 0\n        }\n      }\n      // 这里一大串if和elseif实在是不够优雅\n      // 不过目前就这四个的话懒得再去抽象成接口\n      // 这里用桥接模式的话应该很合适呢\n    }\n\n    /**\n         * 确定弹幕的X坐标\n         * @param style 弹幕类型\n         * @param textWidth 该弹幕的文字内容宽度\n         * @returns x\n         */\n    function generateX (style, textWidth) {\n      if (style === 'scroll') {\n        return that.width\n      } else if (style === 'reversescroll') {\n        return 0\n      } else if (style === 'staticdown' || style === 'staticup') {\n        return (that.width - textWidth) / 2\n      }\n    }\n\n    /**\n         * 检查是否与当前Frame中其他弹幕重叠\n         * @param y 本弹幕y坐标\n         * @param size 本弹幕高度\n         * @param index 当前排序所在的弹幕层\n         * @returns {Boolean} true表示有重叠\n         */\n    function checkDanmu (y, size, index) {\n      let currentLayerDanmus = that.layers[index] // 取得当前弹幕层的所有danmus\n      for (let i = 0; i < currentLayerDanmus.length; i++) {\n        let danmu = currentLayerDanmus[i]\n        if (y + size > danmu.y && y < danmu.y + danmu.height) { // 如果有重叠\n          return true\n        }\n      }\n      return false // 没有重叠\n    }\n    /**\n         * 确定弹幕的y坐标\n         * @param style 弹幕类型\n         * @param size 该弹幕的高(字号)\n         * @param index 当前排序所在的弹幕层\n         * @returns {} {'y坐标':y,'所在弹幕层号index':index}\n         */\n    function generateY (style, size, index) {\n      if (index > 20) {\n        return {\n          'y': 0,\n          'index': index - 1\n        }\n      } // 超过20层就不显示了\n\n      while (!that.layers[index]) { // 如果当前弹幕层还不存在\n        // 增加弹幕层\n        that.layers.push([])\n      }\n\n      let y = 0\n      if (style === 'scroll' || style === 'reversescroll') { // 滚动字幕尽量向顶部聚集,但不重叠\n        while (y < that.height - size) {\n          if (checkDanmu(y, size, index)) {\n            y++\n          } else { // 找到合适位置\n            return {\n              'y': y,\n              'index': index\n            }\n          }\n        }\n      } else if (style === 'staticdown') { // 底部字幕尽量向底部聚集,但不重叠\n        y = that.height - height - 8 // 从底部-文字高度-底部边距的位置开始往上排，默认底部边距是8.\n        while (y > 0) {\n          if (checkDanmu(y, size, index)) {\n            y--\n          } else { // 找到合适位置\n            return {\n              'y': y,\n              'index': index\n            }\n          }\n        }\n      } else if (style === 'staticup') {\n        y = 0 + height + 8 // 注释参见staticdown\n        while (y > 0) {\n          if (checkDanmu(y, size, index)) {\n            y++\n          } else { // 找到合适位置\n            return {\n              'y': y,\n              'index': index\n            }\n          }\n        }\n      }\n\n      // 没有合适位置，再次调用本方法\n      return generateY(style, size, index + 1)\n    }\n  };\n\n  /**\n     * 向Frame中添加自定义弹幕对象.默认渲染在现有层的最后一层即最上层.\n     * @param className 自定义弹幕类\n     */\n  addCustomSprite (info) {\n    if (!this.danmuState) return\n\n    let script = window.document.createElement('script')\n    let className = 'danmuClass__' + new Date().getTime().toString() + '_' + Math.floor((Math.random() * 100000)).toString()\n    let sourceCode = 'var ' + className + ' = ((function() { ' + info.sourceCode + '})()); '\n    script.innerHTML = sourceCode\n    window.document.head.appendChild(script)\n    let customSprite = new (window[className])(info)\n\n    // 高度、宽度等不受系统管理\n    while (!this.layers[this.layers.length - 1]) { // 如果当前弹幕层还不存在\n      this.layers.push([]) // 增加弹幕层\n    }\n    let layer = this.layers.length - 1\n    this.layers[layer].push(customSprite)\n    this.idMap[info.id] = {\n      layer: layer,\n      index: this.layers[layer].length - 1\n    }\n  };\n\n  /**\n     * @Override\n     * 对本帧进行分层渲染\n     */\n  render () {\n    let bufCanvasCtx = this.bufCanvas.getContext('2d')\n    this.ctx.clearRect(0, 0, this.width, this.height) // 清空结果画布\n    bufCanvasCtx.clearRect(0, 0, this.width, this.height) // 清空buffer画布\n    // 渲染各层精灵到buffer画布上\n    for (let i = 0; i < this.layers.length; i++) {\n      for (let j = 0; j < this.layers[i].length; j++) {\n        this.layers[i][j].draw(bufCanvasCtx)\n      }\n    }\n    // 往主图层上绘制buffer图层\n    this.ctx.drawImage(this.bufCanvas, 0, 0)\n  };\n\n  /**\n     * 如果播放器seek了，清空所有弹幕\n     */\n  clearDanmu () {\n    for (let i = 0; i < this.layers.length; i++) {\n      delete this.layers[i] // 删除相应对象\n    }\n    Object.keys(this.idMap, key => delete this.idMap[key])\n    this.idMap = {}\n    this.layers = []\n  };\n\n  /**\n     * 停止显示弹幕，停止创建弹幕\n     */\n  stopDanmu () {\n    this.clearDanmu()\n    this.danmuState = false\n  };\n\n  /**\n     * 重新打开弹幕功能\n     */\n  restartDanmu () {\n    this.danmuState = true\n  };\n  /**\n     * @Override\n     * 更新CommentFrame中弹幕Sprite的状态\n     */\n  updateSprite () {\n    for (let i = 0; i < this.layers.length; i++) {\n      for (let j = 0; j < this.layers[i].length; j++) {\n        // 更新位置\n        this.layers[i][j].move()\n        // 更新生命状态\n        this.layers[i][j].updateLifeTime()\n      }\n    }\n  };\n  /**\n     * @Override\n     * 清除已经死亡的Sprite\n     */\n  clearSprite () {\n    for (let i = 0; i < this.layers.length; i++) {\n      for (let j = 0; j < this.layers[i].length; j++) {\n        if (!this.layers[i][j].alive) {\n          delete this.idMap[this.layers[i][j].id]\n          delete this.layers[i][j] // 删除相应对象\n          this.layers[i] = this.layers[i].slice(0, j).concat(this.layers[i].slice(j + 1, this.layers[i].length)) // 清除数组中该位置\n          for (let l = j; l < this.layers[i].length; l++) {\n            this.idMap[this.layers[i][l].id].index--\n          }\n        }\n      }\n    }\n  };\n\n  /**\n     * @Override\n     * 删除精灵元素\n     * @param sprite\n     */\n  deleteSprites (indexs) {\n    indexs.forEach(index => {\n      if (this.idMap[index]) {\n        if (this.layers[this.idMap[index].layer]) {\n          if (this.layers[this.idMap[index].layer][this.idMap[index].index]) {\n            this.layers[this.idMap[index].layer][this.idMap[index].index].remove()\n          }\n        }\n      }\n    })\n  };\n}\nmodule.exports = CommentFrame\n"
  },
  {
    "path": "app/lib/danmu/frame.js",
    "content": "'use strict'\n\n/**\n * 帧对象\n * 原理就是每到一定时间就清除canvas，然后调用当前帧里的所有的元素的draw()方法。将所有动画元素按照新的配置重画，从而生成动画。\n * 之后程序无需关心元素的重画，只需要调整元素属性即可。\n */\nclass Frame {\n  constructor (width, height, canvasContext) {\n    /**\n     * 帧的宽和高\n     */\n    this.width = width\n    this.height = height\n    /**\n     * 记录绘制Frame的定时器id\n     */\n    this.renderTimer = null\n    /**\n     * 本帧所要绘制的精灵元素\n     */\n    this.sprites = []\n    /**\n     * 保存本帧相关的canvas标签的context\n     */\n    this.ctx = canvasContext\n    /**\n     * 记录FPS\n     */\n    this.fps = 0\n  }\n\n  begin () {\n    if (this.renderTimer !== null) {\n      return // 防止重复启动\n    }\n\n    let that = this;\n    (function animate () {\n      that.updateSprite() // 更新Sprite\n      that.clearSprite() // 清除无效Sprite\n      that.render()\n      that.fps = that.countFps() // 计算FPS\n      that.renderTimer = window.requestAnimationFrame(animate, that)\n    })()\n  };\n\n  /**\n     * 渲染本帧【可根据需要在子对象中复写此方法】\n     */\n  render () {\n    this.ctx.clearRect(0, 0, this.width, this.height)\n    for (let i = 0; i < this.sprites.length; i++) {\n      this.sprites[i].draw(this.ctx)\n    }\n  };\n\n  /**\n     * 停止动画\n     */\n  stop () {\n    if (this.renderTimer === null) {\n      return\n    }\n    window.cancelAnimationFrame(this.renderTimer)\n    this.renderTimer = null\n  };\n\n  /**\n     * 添加精灵元素\n     * @param sprite\n     */\n  addSprite (sprite) {\n    this.sprites.push(sprite)\n  };\n  /**\n     * 删除精灵元素\n     * @param sprite\n     */\n  deleteSprites (indexs) {\n    let that = this\n    indexs.forEach(function (index) {\n      if (that.sprites[index]) {\n        that.sprites[index].remove()\n        that.sprites.splice(index, 1)\n      }\n    })\n  };\n\n  /**\n     * 更新本frame下所有Sprite的位置\n     */\n  updateSprite () {\n    this.sprites.forEach(sprite => {\n      sprite.move()\n    })\n  };\n\n  /**\n     * 清除超出显示范围的精灵元素\n     */\n  clearSprite () {\n    this.sprites.forEach((sprite, index) => {\n      if (sprite.x > this.width || this.sprites[index].y > this.height ||\n                sprite.x + sprite.width < 0 ||\n                sprite.y + sprite.height < 0) {\n        delete this.sprites[index] // 删除相应对象\n        this.sprites = this.sprites.slice(0, index).concat(this.sprites.slice(index + 1, this.sprites.length)) // 清除数组中该位置\n      }\n    })\n  };\n}\n/**\n * 计算FPS\n */\nFrame.prototype.countFps = (function () {\n  let lastLoop = (new Date()).getMilliseconds()\n  let count = 1\n  let fps = 0\n\n  return function () {\n    let currentLoop = (new Date()).getMilliseconds()\n    if (lastLoop > currentLoop) {\n      fps = count\n      count = 1\n    } else {\n      count += 1\n    }\n    lastLoop = currentLoop\n    return fps\n  }\n})()\nmodule.exports = Frame\n"
  },
  {
    "path": "app/lib/danmu/index.js",
    "content": "'use strict'\n\nconst coordinator = require('electron').remote.getGlobal('coordinator')\nconst Player = require('./player')\nconst player = new Player()\n\n// 监听得到弹幕的事件\ncoordinator.on('gotDanmu', data => {\n  player.parseDanmus(data)\n  player.controlDanmu('update')\n})\n// 监听操作相关事件\ncoordinator.on('danmuControl', data => {\n  window.danmuControl[data].call()\n})\n// 删除弹幕事件\ncoordinator.on('deleteDanmu', data => {\n  player.deleteDanmus(data.ids)\n})\n\nfunction send (param) {\n  player.parseDanmus([param])\n  player.controlDanmu('update')\n};\n\nfunction init (config, listener, object) {\n  if (config.image.preload) {\n    config.image.whitelist.map(url => {\n      let img = window.document.createElement('img')\n      img.width = 0\n      img.height = 0\n      img.src = url\n      window.document.body.appendChild(img)\n    })\n  }\n  player.setup(object, 'canvas-danmu')\n  player.controlDanmu('play')\n  window.setInterval(function () {\n    coordinator.emit('fps', player.frame.fps)\n  }, 1000)\n  window.console.log('弹幕初始化完成！')\n};\n\nmodule.exports = {\n  init: init,\n  send: send,\n  example: function () {\n    let i = 1\n    let id = 10000\n    setInterval(function () {\n      send({\n        text: '[IMG WIDTH=24]danmu-24.png[/IMG]测试[IMG WIDTH=24]danmu-24.png[/IMG]Hello World[IMG WIDTH=24]danmu-24.png[/IMG]',\n        color: 'rgb(' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ',' + parseInt(Math.random() * 255) + ')',\n        lifeTime: 500,\n        textStyle: 'normal bold ' + i + 'em 微软雅黑',\n        height: i * 10,\n        id: id\n      })\n      i++\n      if (i > 5) i = 1\n    }, 10)\n  },\n  stop: function () {\n    player.frame.stopDanmu()\n  },\n  start: function () {\n    player.frame.restartDanmu()\n  },\n  clear: function () {\n    player.frame.clearDanmu()\n  }\n}\n\n// 给window添加私货方便调试\nwindow.danmuControl = module.exports\n"
  },
  {
    "path": "app/lib/danmu/player.js",
    "content": "'use strict'\n\nlet path = require('path')\nlet fs = require('fs')\nlet url = require('url')\nlet CommentFrame = require('./commentframe')\n/**\n * 已经审核过的安全Url缓存\n * @type {Array}\n */\nlet safeUrl = []\n/**\n * 检测URL是否合法\n * @param  string content\n * @return bool        \n */\nfunction checkUrlValidate (content) {\n  let regex = config.image.regex\n  regex.lastIndex = 0\n  let ret = null\n  while ((ret = regex.exec(content)) !== null) {\n    let unbelieveUrl = ret[2]\n    if (safeUrl[unbelieveUrl]) continue // 加载缓存\n    let parsedUrl = url.parse(unbelieveUrl)\n    if (parsedUrl.protocol) { // 如果是网络协议就检查白名单\n      return (config.image.whitelist.indexOf(unbelieveUrl) >= 0)\n    }\n    let safePath = path.join('/', unbelieveUrl)\n    let filePath = path.resolve('./' + safePath)\n    let unsafePath = path.resolve(unbelieveUrl)\n    if (filePath !== unsafePath) {\n      return false // 文件在上级目录或其他目录，判定为非法\n    }\n    if (!fs.existsSync(unsafePath)) { // 文件不存在，判定为非法\n      return false\n    }\n  }\n  return true\n}\n/**\n * 弹幕播放器\n * @constructor\n * @param {Integer} insertElement\n * @param {DOMElement} danmuConfig\n */\nclass Player {\n  constructor (insertElement, danmuConfig) {\n    this.insertElement = null\n    // 绘制canvas相关的组件\n    this.canvas = null\n    this.frame = null\n    // 存放解析好的弹幕内容\n    this.danmus = []\n    this.config = danmuConfig\n  }\n\n  /**\n     * 初始化方法\n     */\n  setup (insertObject, elementId) {\n    this.insertElement = insertObject\n    let w = this.insertElement.offsetWidth // 控件的宽\n    let h = this.insertElement.offsetHeight // 控件的高\n    this.canvas = this.addCanvasElement(elementId, w, h)\n    this.insertElement.parentNode.insertBefore(this.canvas, this.insertElement)\n    let canvasContext = this.canvas.getContext('2d')\n    this.frame = new CommentFrame(w, h, canvasContext)\n  }\n\n  /**\n     * 控制弹幕\n     * @param action\n     */\n  controlDanmu (action) {\n    if (action === 'play') {\n      this.frame.begin()\n    } else if (action === 'stop') {\n      this.frame.stop()\n      this.frame.clearDanmu()\n      this.frame.render()\n    } else if (action === 'update') {\n      this.addDanmu()\n    }\n  }\n\n  /**\n     * 创建canvas元素\n     */\n  addCanvasElement (elementId, width, height) {\n    let e = window.document.createElement('canvas')\n    e.id = elementId\n    e.style.position = 'absolute'\n    e.style.zIndex = '1000000'\n    e.style.display = 'block'\n    e.width = width\n    e.height = height\n    return e\n  }\n\n  /**\n     * 将从服务器取得所有弹幕的内容，进行解析，放入this.danmus\n     */\n  parseDanmus (jsonResp) {\n    let nowTime = (new Date()).valueOf()\n\n    this.danmus = []\n    jsonResp.forEach(danmu => {\n      // 先检测图片弹幕\n      if (config.display.image) {\n        if (!checkUrlValidate(danmu.text)) return\n      }\n      danmu.font = danmu.textStyle\n      danmu.lifeTime4TimeStamp = danmu.lifeTime * 1000 / 60\n      danmu.addTime = nowTime\n      danmu.height = parseInt(danmu.height)\n      danmu.lifeTime = parseInt(danmu.lifeTime)\n      this.danmus.push(danmu)\n    })\n  }\n\n  /**\n     * 弹幕精灵添加\n     */\n  addDanmu () {\n    this.danmus.forEach(info => {\n      if (info.style === 'custom') {\n        this.frame.addCustomSprite(info)\n      } else {\n        this.frame.addSprite(info)\n      }\n    })\n  }\n\n  /**\n     * 弹幕精灵删除\n     */\n  deleteDanmus (ids) {\n    this.frame.deleteSprites(ids)\n  }\n\n  /**\n     * 显示/隐藏弹幕的处理函数\n     */\n  toggleDanmu () {\n    if (this.frame.visible) { // 弹幕可见\n      this.frame.clearDanmu() // 情况当前所有待渲染弹幕\n      this.frame.render() // 重绘一帧空的屏幕\n      this.frame.stop() // 停止Frame\n      this.frame.visible = false // 设置弹幕标记为不可见\n    } else { // 弹幕隐藏\n      this.frame.begin()\n      this.frame.visible = true\n    }\n  }\n}\nmodule.exports = Player\n"
  },
  {
    "path": "app/lib/danmu/sprite.js",
    "content": "'use strict'\n\n/**\n * 精灵对象\n * 所有的动画元素都必须继承自此对象,继承之后自动拥有move方法、速度属性、删除自身方法.\n * 每个动画元素都必须拥有一个自己的特殊的draw()方法的实现,这个方法用来在渲染每一帧的时候指定自己如何呈现在每一帧（frame.js）上\n * @param id\n * @param x 精灵相对与画布的位置\n * @param y\n * @param width 精灵的宽\n * @param height\n * @param speed\n * @param lifeTime\n * @param alive\n * @returns {Sprite}\n */\nclass Sprite {\n  constructor (id, x, y, width, height, speed, lifeTime, alive) {\n    this.id = id || 0\n    this.x = x || 0\n    this.y = y || 0\n    this.width = width || 0\n    this.height = height || 0\n    this.lifeTime = lifeTime || 0\n    this.alive = alive || true\n    this.children = []\n    /**\n         * 精灵移动速度\n         */\n    this.speed = speed || {\n      x: 0,\n      y: 0\n    }\n  }\n\n  draw () {\n\n  }\n\n  move () {\n    this.x += this.speed.x\n    this.y += this.speed.y\n    if (typeof this.children !== 'undefined') {\n      for (let i = 0; i < this.children.length; i++) {\n        this.children[i].speed = this.speed\n        this.children[i].move()\n      }\n    }\n  }\n  /**\n         * 向此精灵添加一个子精灵\n         */\n  appendChild (sprite) {\n    this.children.push(sprite)\n  }\n  /**\n         * 渲染子精灵\n         */\n  drawChildren () {\n    this.children.forEach(function (child) {\n      child.draw()\n    })\n  }\n\n  /**\n     * 删除自身\n     */\n  remove () {\n    this.lifeTime = 0\n    this.alive = false\n  };\n}\n\nmodule.exports = Sprite\n"
  },
  {
    "path": "app/lib/listener/index.js",
    "content": "/* global config */\n'use strict'\nconst packageJson = require('../../package.json')\nconst player = require('../danmu/index')\nconst Socket = require('socket.io-client')\nconst coordinator = require('electron').remote.getGlobal('coordinator')\n\nlet io\nlet serverRandomNumber = null\nmodule.exports = {\n  init: function () {\n    io = Socket(config.socket.url)\n    io.heartbeatTimeout = config.socket.heartbeat\n    realInit(config)\n  }\n}\n\nfunction realInit () {\n  let initCount = 0\n  io.on('init', () => {\n    initCount++\n    io.emit('password', {\n      password: config.socket.password,\n      room: config.socket.room,\n      info: {\n        version: packageJson.version\n      }\n    })\n    if (initCount > 1) {\n      window.console.log('连接密码错误')\n    }\n  })\n  io.on('connected', data => {\n    initCount = 0\n    window.console.log('已连接上弹幕服务器（' + data.version + '）')\n    if (serverRandomNumber !== data.randomNumber) {\n      if (serverRandomNumber !== null) {\n        window.console.log('服务器似乎已重启，将清空弹幕池。')\n        // 如果断线（服务器重启？）了，必须清理原有弹幕，否则会导致ID池不匹配\n      }\n      player.stop()\n      player.clear()\n      player.start()\n      serverRandomNumber = data.randomNumber\n    }\n  })\n  io.on('disconnect', () => {\n    window.console.warn('与服务器的连接中断')\n  })\n  io.on('danmu', data => {\n    window.console.log('得到' + data.data.length + '条弹幕')\n    coordinator.emit('gotDanmu', data.data)\n  })\n  io.on('delete', data => {\n    window.console.log('删除' + data.ids.length + '条弹幕')\n    coordinator.emit('deleteDanmu', data)\n  })\n};\n"
  },
  {
    "path": "app/lib/utils/index.js",
    "content": "'use strict'\nconst url = require('url')\nconst path = require('path')\n\nclass ImageCache {\n  constructor () {\n    this.cache = []\n    this.regex = null\n  }\n\n  test (callback, text) {\n    if (!this.regex) return\n    // Initialize here\n    let ret = null\n    this.regex.lastIndex = 0\n    // Analyze text\n    while ((ret = this.regex.exec(text)) !== null) {\n      let src = ret[2]\n      let imageWidth = parseInt(ret[1])\n      let imageObject = this.getImage(src)\n      if (imageObject === null) {\n        imageObject = this.buildCache(src, imageWidth)\n      }\n      callback(ret, imageObject)\n    }\n    this.regex.lastIndex = 0\n  }\n\n  getImage (src) {\n    return this.cache[src] || null\n  }\n\n  buildCache (src, width) {\n    let parsedUrl = url.parse(src)\n    let image = window.document.createElement('img')\n    image.src = parsedUrl.protocol ? src : path.resolve('./', './' + src)\n    image.width = width\n    image.onerror = function () {\n      window.console.error('Cannot load ' + src)\n      this.cache[src].error = true\n    }\n    this.cache[src] = {\n      error: false,\n      element: image,\n      width: width\n    }\n    return this.cache[src]\n  }\n}\nlet images = new ImageCache()\n\nfunction tryCatch (fn, fnCatch) {\n  try {\n    fn()\n  } catch (e) {\n    fnCatch(e)\n  }\n}\n\nmodule.exports = {\n  imageAnalyzer: images,\n  tryCatch\n}\n"
  },
  {
    "path": "app/main.js",
    "content": "const electron = require('electron')\nconst path = require('path')\nconst {app, BrowserWindow} = electron\nglobal.coordinator = new (require('events').EventEmitter)()\nglobal.windows = {}\n\ncoordinator.on('exit', () => {\n  app.exit(0)\n})\napp.on('window-all-closed', () => {\n  app.exit(0)\n})\n\napp.on('ready', () => {\n  windows.panelWindow = new BrowserWindow({\n    width: 390,\n    height: 150,\n    resizable: false,\n    icon: `${__dirname}/danmu.png`\n  })\n  windows.panelWindow.loadURL(`file://${__dirname}/panel.html`)\n  windows.panelWindow.on('closed', () => {\n    // app.exit();\n  })\n  windows.panelWindow.setMenu(null)\n\n  windows.mainWindow = new BrowserWindow({\n    transparent: true,\n    frame: false,\n    toolbar: false,\n    resizable: true,\n    title: 'DANMU Client',\n    alwaysOnTop: true,\n    icon: path.join(__dirname, '/danmu.png')\n  })\n  windows.mainWindow.loadURL(`file://${__dirname}/index.html`)\n  windows.mainWindow.on('closed', () => {\n    coordinator.emit('exit')\n  })\n  windows.mainWindow.setMenu(null)\n})\n"
  },
  {
    "path": "app/package.json",
    "content": "{\n  \"name\": \"danmu-client\",\n  \"version\": \"1.1.0-dev\",\n  \"description\": \"Danmu client\",\n  \"main\": \"./main.js\",\n  \"author\": \"zsx <zsx@zsxsoft.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://github.com/zsxsoft/danmu-client\",\n  \"dependencies\": {\n    \"socket.io-client\": \"^2.0.3\",\n    \"windows-caption-color\": \"0.0.6\"\n  }\n}\n"
  },
  {
    "path": "app/panel.html",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n\n<head>\n    <meta charset=\"UTF-8\">\n    <style type=\"text/css\">\n    body {\n        font-family: 'Segoe UI Light', 'Segoe UI', 'Segoe WP', 'Helvetica Neue', '微软雅黑 Light', '微软雅黑', sans-serif;\n        font-size: 1rem;\n        line-height: 1.5;\n        color: #373a3c;\n    }\n    \n    .btn {\n        display: block;\n        display: block;\n        cursor: pointer;\n        -webkit-transition: -webkit-transform .2s;\n        float: left;\n        min-width: 75px;\n        text-align: center;\n        opacity: .75;\n        background-color: #2e8bcc;\n        z-index: 1;\n        margin-right: 2px;\n        color: #fff;\n        height: 70px;\n        width: 70px;\n        font-family: 'Segoe UI Light', 'Segoe UI', 'Segoe WP', 'Helvetica Neue', '微软雅黑 Light', '微软雅黑', sans-serif;\n        border: none;\n    }\n    \n    .btn:hover {\n        opacity: 1;\n    }\n    \n    .center {\n        position: absolute;\n        top: 10%;\n        transform: translate(10%, 10%);\n    }\n    </style>\n    <title>DANMU Panel</title>\n</head>\n\n<body>\n    <div class=\"center\">\n        <button class=\"btn btn-success btn-control\" data-top=\"danmuControl\" data-param=\"start\">接收弹幕</button>\n        <button class=\"btn btn-success btn-control\" data-top=\"danmuControl\" data-param=\"stop\">停收弹幕</button>\n        <button class=\"btn btn-success btn-control\" data-top=\"danmuControl\" data-param=\"clear\">清空弹幕</button>\n        <button class=\"btn btn-success btn-control\" data-top=\"windowControl\" data-param=\"quit\" id=\"btn-quit\">退出弹幕</button>\n    </div>\n</body>\n<script src=\"panel.js\"></script>\n\n</html>\n"
  },
  {
    "path": "app/panel.js",
    "content": "(function () {\n  const electron = require('electron')\n  const windows = electron.remote.getGlobal('windows')\n  const coordinator = electron.remote.getGlobal('coordinator')\n  const controlButtons = Array.from(window.document.querySelectorAll('.btn-control')) // I think querySelectorAll's api is terrible.\n  let countQuitValue = 0\n  let isShow = true\n\n  function controlButtonClick () {\n    coordinator.emit(this.getAttribute('data-top'), this.getAttribute('data-param'))\n  }\n\n  coordinator.on('fps', fps => {\n    if (!isShow) return\n    document.title = 'FPS: ' + fps\n  })\n\n  window.addEventListener('beforeunload', e => {\n    // Hide but not exit\n    // We cannot call a function that in a unregistered window.\n    e.returnValue = 'false'\n    windows.panelWindow.hide()\n    isShow = false\n  })\n\n  window.addEventListener('keydown', e => {\n    if (e.keyCode === 123) { // F12\n      windows.panelWindow.webContents.openDevTools({\n        detach: true\n      })\n    }\n  }, true)\n\n  document.querySelector('#btn-quit').addEventListener('click', () => {\n    if (countQuitValue === 1) {\n      coordinator.emit('exit')\n    } else {\n      setTimeout(() => {\n        document.querySelector('#btn-quit').innerText = '退出程序'\n        countQuitValue = 0\n      }, 5000)\n      this.innerText = '再按一次'\n      countQuitValue = 1\n    }\n    return false\n  })\n\n  controlButtons.forEach(item => {\n    item.addEventListener('click', controlButtonClick)\n  })\n\n  require('windows-caption-color').get((err, ret) => {\n    if (!err) {\n      window.document.body.style.background = 'rgba(' + ret.reg.r + ', ' + ret.reg.g + ', ' + ret.reg.b + ', ' + ret.reg.a + ')'\n    }\n  })\n})()\n"
  },
  {
    "path": "appveyor.yml",
    "content": "environment:\n  nodejs_version: \"8\"\n\ncache:\n  - node_modules\n  - '%APPDATA%\\npm-cache'\n  - '%USERPROFILE%\\.electron'\n\ninstall:\n  - ps: Install-Product node $env:nodejs_version\n  - npm install\n\ntest_script:\n  - node --version\n  - npm --version\n\nbuild_script:\n  - npm run release\n\nshallow_clone: true\nclone_depth: 1"
  },
  {
    "path": "config.js",
    "content": "({\n\tsocket: {\n\t\turl: \"http://127.0.0.1:3000\",\n\t\tpassword: \"\",\n\t\troom: \"unlimited\",\n\t\theartbeat: 3000\n\t},\n\tdisplay: {\n\t\tcomment: {\n\t\t\tanimationStyle: \"scroll\",\n\t\t\tfontStyle: \"normal bold 5em 微软雅黑\",\n\t\t\tfontColor: \"rgb(255, 255, 255)\",\n\t\t\tlifeTime: 240,\n\t\t\theight: 50\n\t\t},\n\t\timage: true\n\t},\n\timage: {\n\t\tregex: /\\[IMG WIDTH=(\\d+)\\](.+?)\\[\\/IMG\\]/ig,\n\t\twhitelist: [\n\t\t\t\"https://www.baidu.com/img/bd_logo1.png\",\n\t\t\t\"http://www.baidu.com/img/bd_logo1.png\",\n\t\t]\n\t}\n});\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"danmu-client\",\n  \"version\": \"1.1.0\",\n  \"description\": \"Danmu client\",\n  \"main\": \"app/main.js\",\n  \"scripts\": {\n    \"start\": \"electron .\",\n    \"build\": \"electron-builder\",\n    \"release\": \"build\"\n  },\n  \"build\": {\n    \"productName\": \"danmu-client\",\n    \"appId\": \"com.zsxsoft.danmu.client\",\n    \"compression\": \"maximum\",\n    \"extraFiles\": [\n      \"config.js\"\n    ],\n    \"mac\": {\n      \"category\": \"public.app-category.utilities\",\n      \"target\": [\n        \"zip\"\n      ]\n    },\n    \"win\": {\n      \"target\": [\n        \"7z\"\n      ]\n    },\n    \"linux\": {\n      \"target\": [\n        \"zip\"\n      ]\n    },\n    \"directories\": {\n      \"buildResources\": \"resources\"\n    }\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"https://github.com/zsxsoft/danmu-client.git\"\n  },\n  \"keywords\": [\n    \"danmu\",\n    \"danmaku\"\n  ],\n  \"author\": \"zsx <zsx@zsxsoft.com>\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/zsxsoft/danmu-client/issues\"\n  },\n  \"homepage\": \"https://github.com/zsxsoft/danmu-client\",\n  \"dependencies\": {\n    \"socket.io-client\": \"^2.0.3\",\n    \"windows-caption-color\": \"0.0.6\"\n  },\n  \"devDependencies\": {\n    \"7zip-bin\": \"^2.2.3\",\n    \"babel-eslint\": \"^7.2.3\",\n    \"electron\": \"^1.7.5\",\n    \"electron-builder\": \"^19.22.1\",\n    \"eslint\": \"^4.5.0\",\n    \"eslint-config-standard\": \"^10.2.1\",\n    \"eslint-plugin-import\": \"^2.7.0\",\n    \"eslint-plugin-node\": \"^5.1.1\",\n    \"eslint-plugin-promise\": \"^3.5.0\",\n    \"eslint-plugin-standard\": \"^3.0.1\",\n    \"node-gyp\": \"^3.6.2\"\n  }\n}\n"
  },
  {
    "path": "readme.md",
    "content": "danmu-client\n==========\n[![Github All Releases](https://img.shields.io/github/downloads/zsxsoft/danmu-client/total.svg)](https://github.com/zsxsoft/danmu-client/releases)\n[![David deps](https://david-dm.org/zsxsoft/danmu-client.svg)](https://david-dm.org/zsxsoft/danmu-client)\n\n这是一个独立的弹幕客户端，其服务端项目见[danmu-server](https://github.com/zsxsoft/danmu-server)。屏幕截图见[Release](https://github.com/zsxsoft/danmu-client/releases)。\n\n**欲使用此项目，服务端需要使用对应的版本。[已发布的客户端](https://github.com/zsxsoft/danmu-client/releases)均已写明对应的服务端版本号，开发分支内的客户端版本仅对应开发分支的服务端。**\n\n## 功能特色\n- 以``WebSocket``作为通讯协议，用``Canvas``作为弹幕的画布；\n- 可在桌面任何位置显示弹幕，可与其他程序（如PowerPoint、视频播放器等）共同工作；\n- 窗口置于最前，完全透明可穿透，能与其他程序正常交互；\n- 提供紧急清空弹幕池、停止接收弹幕等功能，可删除单条弹幕；\n- 支持图片弹幕。\n\n### 适用场景\n- 数十人至千人集会（如学校活动、电影放映会）的实时互动\n- 大型活动实时公告信息显示\n- 欲在桌面显示实时吐槽\n\n## 最低系统要求\n\nWindows 7 / macOS 10.9 (x64) / Ubuntu 12.04 / Fedora 21 / Debian 8\n\n## 使用预编译版本\n\n1. 打开[Release](https://github.com/zsxsoft/danmu-client/releases)下载已经编译好的程序包并解压到某目录。\n2. 双击目录下的``danmu-client``，启动成功。\n\n## 从源码启动\n\n1. 下载并安装[Nodejs](https://nodejs.org)，并检查[node-gyp](https://github.com/nodejs/node-gyp)的依赖环境。\n2. ``npm install``\n3. ``npm start``\n\n## 打包发布\n\n``npm run build``\n\n## 配置说明\n根目录``config.js``下有配置，以下是说明\n\n    socket: {\n        url: \"弹幕服务器开启的IP与端口（如使用反代，需要确认反代支持WebSocket连接）\",\n        password: \"弹幕服务器连接密码\",\n        room: \"选择连接弹幕服务器的某间房间\",\n        heartbeat: 心跳包发送间隔\n    },\n    display: {\n        comment: {\n            animationStyle: \"默认弹幕样式（支持scroll、reversescroll、staticdown、staticup）\",\n            fontStyle: \"默认字体样式\",\n            fontColor: \"默认颜色\",\n            lifeTime: 每条弹幕的基本存活时间,\n            height: 每条弹幕占据高度\n        }, \n        image: 图片弹幕开关\n    }, \n    image: {\n        regex: 图片判断正则，勿动\n        whitelist: [\n            \"图片弹幕允许加载的网络图片白名单。\", \n            \"不支持通配符，必须一条一条手动添加。\", \n            \"请确认图片可以正常被打开。\"\n        ], \n        preload: 网络图片是否预读缓存\n    }\n\n## 图片弹幕\n打开图片弹幕开关后，弹幕内含相关内容的将被解析为图片。图片必须可以正常打开，调用代码如：``[IMG WIDTH=24]danmu-24.png[/IMG]``。格式：``[IMG WIDTH=图片宽度]图片地址（支持HTTP）[/IMG]``\n\n为了保证安全与稳定，图片弹幕有防火墙机制。只有在弹幕程序目录及子目录下存在的图片才可被加载。引用网络图片，必须手动修改``config.js``添加白名单规则。如果被过滤，则程序不会有任何提示，该弹幕也不会被显示。\n\n## 自定义弹幕\n需要在服务器打开相应开关后，才允许使用自定义弹幕功能。自定义弹幕必须返回一个函数（或类），继承自``lib/danmu/sprite.js``中的``Sprite``，并需要实现``updateLifeTime``方法和``draw``方法，有``alive``属性。__为确保效率，自定义弹幕未加入错误捕捉。一旦函数出错，则弹幕系统停止接受新弹幕。__\n示例代码如下（生成一个颜色随机、在屏幕上晃来晃去的玩意）：\n\n### 最新版示例代码 \n```javascript\nreturn (() => {\n    'use strict';\n    const Sprite = require('./lib/danmu/sprite');\n    let canvasWidth = 0;\n    let canvasHeight = 0;\n    class Comment extends Sprite {\n        constructor(param) {\n            super(param.id, param.x, param.y, param.width, param.height, param.speed, param.lifeTime);\n            this.text = param.text || \"\"; //文字内容\n            this.lifeTime = param.lifeTime || config.display.comment.lifeTime;\n            this.font = param.font || config.display.comment.fontStyle;\n        }\n        draw(canvasContext) {\n            if (canvasWidth === 0) canvasWidth = canvasContext.canvas.width;\n            if (canvasHeight === 0) canvasHeight = canvasContext.canvas.height;\n            canvasContext.fillStyle = `rgb(${parseInt(Math.random() * 255)}, ${parseInt(Math.random() * 255)}, ${parseInt(Math.random() * 255)})`;\n            canvasContext.font = this.font;\n            canvasContext.fillText(this.text, parseInt(Math.random() * canvasWidth), parseInt(Math.random() * canvasHeight));\n        }\n        updateLifeTime() {\n            this.lifeTime--; //每刷新一帧，存活时间-1\n            this.alive = (this.lifeTime >= 0);\n        };\n    }\n    return Comment;\n})();\n\n```\n\n\n## 开源协议\nThe MIT License (MIT)\n\n\n## 博文\n[弹幕服务器及搭配之透明弹幕客户端研究结题报告](https://blog.zsxsoft.com/post/15)\n\n[弹幕服务器及搭配之透明弹幕客户端研究中期报告](https://blog.zsxsoft.com/post/14)\n\n[弹幕服务器及搭配之透明弹幕客户端研究开题报告](https://blog.zsxsoft.com/post/13)\n\n## 开发者\nzsx - https://www.zsxsoft.com / 博客 - https://blog.zsxsoft.com\n\n## 感谢\n[DDPlayer](https://github.com/dpy1123/ddplayer) by dpy1123\n"
  }
]