章节内容
历史介绍
开发环境
代码封装
函数式
获取小说命令行版(上)
获取小说命令行版(中)
获取小说命令行版(下)
嵌入到应用中
插件机制
文字转语音
设置与信号中断
插件商店
下载与删除(上)
下载与删除(下)
通知栏程序
播放功能
美化界面
数据可视化(上)
数据可视化(中)
数据可视化(下)
嵌入到应用中
打包应用

这一小节,我们将我们的程序改造成通知栏程序。

# 安装依赖

其实通知栏的程序就是去掉窗口的边框,然后定位到通知栏小图标的下面,通知栏是可以获得它的位置坐标的,我们可以基于这个坐标进行计算来获得。这个我们可以使用electron-positioner 来帮助我们进行计算,大家也可以参考 menubar 项目进行改造。

npm install electron-positioner --save

别忘记自己添加一下定义文件,electron-positioner.d.ts

declare module 'electron-positioner'

# 修改 tray.ts

由于我们需要控制顺序,ready 里面的顺序分开来看不是特别明显,所以我们提取到 index.ts 中,我们需要自定义 Tray 小图标的一些单击,双击,右键的事件,当计算的距离是 tray 开头的时候,需要传入 tray.getBounds() 获取的小图标坐标点。

import Positioner from 'electron-positioner'
import { opensetting } from './tray'

let tray: Tray
let positioner: any

function setPostion(win: BrowserWindow) {
  // 得到位置
  positioner = new Positioner(win)
  positioner.move('trayCenter', tray.getBounds())
  win.show()
}

function createTray() {
  // 创建通知栏图标
  tray = new Tray(resolve(__dirname, 'tray_w24h24.png'))
  const contextMenu = Menu.buildFromTemplate([
    { label: '设置', click: opensetting },
    {
      label: '退出',
      role: 'quit'
    }
  ])
  const toggle = () => {
    // 显示隐藏
    if (!mainWindow) {
      return
    }
    if (mainWindow.isVisible()) {
      return mainWindow.hide()
    }
    positioner.move('trayCenter', tray.getBounds())
    mainWindow.show()
  }
  tray.on('click', toggle) // 单击
  tray.on('double-click', toggle) // 双击
  tray.on('right-click', () => {
    // 右键菜单
    tray.popUpContextMenu(contextMenu)
  })
}

async function ready() {
  mainWindow = createMainWindow({
    width: 400,
    height: 560,
    frame: false,
    transparent: true,
    show: false
  })
  createTray()
  setPostion(mainWindow)
  pluginSetUp()
  crawlSetUp()
}

# 解决 a 标签的拖拽问题

这个时候有一个小 bug , 所有的跳转按钮是可拖动的,我们需要阻止一下默认事件。

this.refs.link.addEventListener('dragstart', e => {
  e.preventDefault()
})

Tray

# 添加状态

我们需要一个状态来标记是否已经开始了队列。以及添加一个警告的方法。

const store = new Store({
  currentPage: Main,
  msg: {
    type: 'success',
    content: ''
  },
  start: false // 开始队列否
})

function warring(content, timer = 1000) {
  store.set({
    msg: {
      type: 'warring',
      content
    }
  })
  setTimeout(resetMsg, timer) // 自动关闭消息
}

store.warring = warring.bind(store)

# 修改 Download.svelte

添加模板逻辑,访问全局状态,以 $ 开头。需要一个 canvas 来画进度框。包裹一层是为了居中,一定要在属性上面给确定的大小,要不然会画出来的东西就看不到了。对于 canvas 我也有录制过视屏,在这里 ,不过不是免费的内容。

canvas 一定要通过 css 控制显隐,要不然会很难操作,动态挂载生命周期极其难控制。

<Back/>
<div class="wrap">
    {#if !$start}
        <label>文件名</label>
        <input bind:value=folderName />
        <label>爬取网址</label>
        <input bind:value=url />
    {/if}
    <div class="oprator">
        {#if !$start}
            <button on:click="download()">下载</button>
        {:else}
            <button on:click="stop()">中断</button>
        {/if}
    </div>

        {#if status.type }
            <div class="type">
                {status.type == 'crawl' && status.step == 'chapter' ?  '爬取章节': ''}
                {status.type == 'crawl' && status.step == 'text'? '爬取内容': ''}
                {status.type == 'audio'? '转换音频': ''}
            </div>
        {/if}

        {#if status.title}
            <div class="title">
                {status.title}
            </div>
        {/if}

        <div class="box {$start?'show':''}">
            <canvas ref:canvas id="canvas" width="250" height="250"></canvas>
        </div>
</div>

定义了颜色 color 它代表每一阶段进度条的颜色,然后在 oncreate 的时候,绘制 canvas。  这里我对所有的错误相关的进行了重构,错误消息都是 type: 'error' 的结构。当中止队列的时候不要忘记清空状态。once 是用来控制消息提示只调用一次,调用多次会出现 Bug,暂时无法找到何处出了问题,猜测是内置动画的原因。

对于绘制进度条,使用了两个圆,通过 arc API 进行了绘制,然后通过 requestAnimationFrame 逐帧绘制。每次绘制都会去状态里面取最新的值。

对于百分比其实很简单,2 * PI / 100 就是百分之一 ,乘以百分比即可。为了保证画布的干净每次都需要 clearRect

<script>
  import { ipcRenderer } from "electron";

  const color = {
    text: "#00CC99",
    audio: "#3399CC",
    stop: "#FF3333"
  };

  export default {
    data() {
      return {
        url: "https://www.ybdu.com/xiaoshuo/0/910/",
        folderName: "123",
        status: {
          type: " ",
          title: " ",
          percent: 0
        }
      };
    },
    components: {
      Back: "../components/Back.svelte"
    },
    oncreate() {
      let once = false;
      ipcRenderer.on("download-status", (event, args) => { // 接受下载状态,并渲染到 canvas
        if (
          (args.typw == "audio" && args.percent == 100) ||
          args.type == "stop"
        ) {
          this.set({
            status: {
              type: "",
              percent: 0
            }
          });
          this.store.set({ start: false });

          if (once) {
            once = false;
            return;
          }
          this.store.success(args.message);
          once = true;
          return;
        }

        if (args.type == "error") {
          this.store.warring(args.message);
          this.store.set({ start: false });
          return;
        }

        this.set({
          status: args
        });
        this.store.set({
          start: true
        });
      });

      this.draw();
      const drawFrame = () => {
        if (!this) {
          return;
        }
        this.context.clearRect(0, 0, this.centerX * 2, this.centerY * 2);
        this.text();
        this.whiteCircle();
        this.blueCircle();
        this && window.requestAnimationFrame(drawFrame); // 每帧自动渲染
      };
      drawFrame();
    },
    ondestroy() {
      ipcRenderer.removeAllListeners("download-status");
    },
    methods: {
      download() {
        const { url, folderName } = this.get();
        if (url.length && folderName.length) {
          ipcRenderer.send("download", {
            url,
            folderName
          });
          this.store.set({ start: true });
        }
      },
      stop() {
        ipcRenderer.send("stop");
      },
      draw() {
        let canvas = document.querySelector("#canvas");
        this.context = canvas.getContext("2d");
        this.centerX = canvas.width / 2;
        this.centerY = canvas.height / 2;
        this.rad = Math.PI * 2 / 100;
      },
      text() {
        const { status } = this.get();
        if (!status) {
          return;
        }
        this.context.save(); // 保存之前的转态。
        this.context.fillStyle = "#888";
        this.context.font = "40px Arial";
        this.context.textAlign = "center";
        this.context.textBaseline = "middle";
        this.context.fillText(status.percent + "%", this.centerX, this.centerY);
        this.context.restore(); // 恢复之前的转态。
      },
      blueCircle() { // 上层进度全
        const { status } = this.get();
        if (!status) {
          return;
        }
        this.context.save();
        this.context.beginPath();
        this.context.strokeStyle = color[status.step] || "#9900FF"; // 绘制颜色
        this.context.lineWidth = 12;
        this.context.arc( // 绘制圆
          this.centerX,
          this.centerY,
          100,
          -Math.PI / 2,
          -Math.PI / 2 + status.percent * this.rad,
          false
        );
        this.context.stroke();
        this.context.restore();
      },
      whiteCircle() { // 底层白圈
        this.context.save();
        this.context.beginPath();
        this.context.strokeStyle = "#383a41";
        this.context.lineWidth = 12;
        this.context.arc(this.centerX, this.centerY, 100, 0, Math.PI * 2, false);
        this.context.stroke();
        this.context.closePath();
        this.context.restore();
      }
    }
  };
</script>

# 添加重试与等待机制

有的时候会报 getaddrinfo ENOTFOUND 错误,我们添加一个重试的机制。

const getMp3Data: any = async (text: string, opts: any = {}) => {
  const textArr = splitText(text)
  return Promise.all(
    textArr.map(async chunk => {
      const { data } = await client.text2audio(chunk, opts)
      return data
    })
  ).catch(() => getMp3Data(text, opts))
}

再添加一个等待机制,自行到 tray.ts 里面添加配置哦,这样可以减小网络出错的概率。

await timer(TTS_WAIT).toPromise()

# 忽略错误

在测试的时候,发现  总是有的时候会遇见 mp3-concat 有错误,会弹出选项框,这就很不友好,但是实际内容是没有丢失的,我们可以直接忽略掉它,修改 index.ts

process.on('unhandledRejection', console.log)
process.on('uncaughtException', console.log)

download