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

安装依赖

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

  1. npm install electron-positioner --save

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

  1. declare module 'electron-positioner'

修改 tray.ts

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

  1. import Positioner from 'electron-positioner'
  2. import { opensetting } from './tray'
  3. let tray: Tray
  4. let positioner: any
  5. function setPostion(win: BrowserWindow) {
  6. // 得到位置
  7. positioner = new Positioner(win)
  8. positioner.move('trayCenter', tray.getBounds())
  9. win.show()
  10. }
  11. function createTray() {
  12. // 创建通知栏图标
  13. tray = new Tray(resolve(__dirname, 'tray_w24h24.png'))
  14. const contextMenu = Menu.buildFromTemplate([
  15. { label: '设置', click: opensetting },
  16. {
  17. label: '退出',
  18. role: 'quit'
  19. }
  20. ])
  21. const toggle = () => {
  22. // 显示隐藏
  23. if (!mainWindow) {
  24. return
  25. }
  26. if (mainWindow.isVisible()) {
  27. return mainWindow.hide()
  28. }
  29. positioner.move('trayCenter', tray.getBounds())
  30. mainWindow.show()
  31. }
  32. tray.on('click', toggle) // 单击
  33. tray.on('double-click', toggle) // 双击
  34. tray.on('right-click', () => {
  35. // 右键菜单
  36. tray.popUpContextMenu(contextMenu)
  37. })
  38. }
  39. async function ready() {
  40. mainWindow = createMainWindow({
  41. width: 400,
  42. height: 560,
  43. frame: false,
  44. transparent: true,
  45. show: false
  46. })
  47. createTray()
  48. setPostion(mainWindow)
  49. pluginSetUp()
  50. crawlSetUp()
  51. }

解决 a 标签的拖拽问题

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

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

Tray

添加状态

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

  1. const store = new Store({
  2. currentPage: Main,
  3. msg: {
  4. type: 'success',
  5. content: ''
  6. },
  7. start: false // 开始队列否
  8. })
  9. function warring(content, timer = 1000) {
  10. store.set({
  11. msg: {
  12. type: 'warring',
  13. content
  14. }
  15. })
  16. setTimeout(resetMsg, timer) // 自动关闭消息
  17. }
  18. store.warring = warring.bind(store)

修改 Download.svelte

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

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

  1. <Back/>
  2. <div class="wrap">
  3. {#if !$start}
  4. <label>文件名</label>
  5. <input bind:value=folderName />
  6. <label>爬取网址</label>
  7. <input bind:value=url />
  8. {/if}
  9. <div class="oprator">
  10. {#if !$start}
  11. <button on:click="download()">下载</button>
  12. {:else}
  13. <button on:click="stop()">中断</button>
  14. {/if}
  15. </div>
  16. {#if status.type }
  17. <div class="type">
  18. {status.type == 'crawl' && status.step == 'chapter' ? '爬取章节': ''}
  19. {status.type == 'crawl' && status.step == 'text'? '爬取内容': ''}
  20. {status.type == 'audio'? '转换音频': ''}
  21. </div>
  22. {/if}
  23. {#if status.title}
  24. <div class="title">
  25. {status.title}
  26. </div>
  27. {/if}
  28. <div class="box {$start?'show':''}">
  29. <canvas ref:canvas id="canvas" width="250" height="250"></canvas>
  30. </div>
  31. </div>

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

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

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

  1. <script>
  2. import { ipcRenderer } from "electron";
  3. const color = {
  4. text: "#00CC99",
  5. audio: "#3399CC",
  6. stop: "#FF3333"
  7. };
  8. export default {
  9. data() {
  10. return {
  11. url: "https://www.ybdu.com/xiaoshuo/0/910/",
  12. folderName: "123",
  13. status: {
  14. type: " ",
  15. title: " ",
  16. percent: 0
  17. }
  18. };
  19. },
  20. components: {
  21. Back: "../components/Back.svelte"
  22. },
  23. oncreate() {
  24. let once = false;
  25. ipcRenderer.on("download-status", (event, args) => { // 接受下载状态,并渲染到 canvas
  26. if (
  27. (args.typw == "audio" && args.percent == 100) ||
  28. args.type == "stop"
  29. ) {
  30. this.set({
  31. status: {
  32. type: "",
  33. percent: 0
  34. }
  35. });
  36. this.store.set({ start: false });
  37. if (once) {
  38. once = false;
  39. return;
  40. }
  41. this.store.success(args.message);
  42. once = true;
  43. return;
  44. }
  45. if (args.type == "error") {
  46. this.store.warring(args.message);
  47. this.store.set({ start: false });
  48. return;
  49. }
  50. this.set({
  51. status: args
  52. });
  53. this.store.set({
  54. start: true
  55. });
  56. });
  57. this.draw();
  58. const drawFrame = () => {
  59. if (!this) {
  60. return;
  61. }
  62. this.context.clearRect(0, 0, this.centerX * 2, this.centerY * 2);
  63. this.text();
  64. this.whiteCircle();
  65. this.blueCircle();
  66. this && window.requestAnimationFrame(drawFrame); // 每帧自动渲染
  67. };
  68. drawFrame();
  69. },
  70. ondestroy() {
  71. ipcRenderer.removeAllListeners("download-status");
  72. },
  73. methods: {
  74. download() {
  75. const { url, folderName } = this.get();
  76. if (url.length && folderName.length) {
  77. ipcRenderer.send("download", {
  78. url,
  79. folderName
  80. });
  81. this.store.set({ start: true });
  82. }
  83. },
  84. stop() {
  85. ipcRenderer.send("stop");
  86. },
  87. draw() {
  88. let canvas = document.querySelector("#canvas");
  89. this.context = canvas.getContext("2d");
  90. this.centerX = canvas.width / 2;
  91. this.centerY = canvas.height / 2;
  92. this.rad = Math.PI * 2 / 100;
  93. },
  94. text() {
  95. const { status } = this.get();
  96. if (!status) {
  97. return;
  98. }
  99. this.context.save(); // 保存之前的转态。
  100. this.context.fillStyle = "#888";
  101. this.context.font = "40px Arial";
  102. this.context.textAlign = "center";
  103. this.context.textBaseline = "middle";
  104. this.context.fillText(status.percent + "%", this.centerX, this.centerY);
  105. this.context.restore(); // 恢复之前的转态。
  106. },
  107. blueCircle() { // 上层进度全
  108. const { status } = this.get();
  109. if (!status) {
  110. return;
  111. }
  112. this.context.save();
  113. this.context.beginPath();
  114. this.context.strokeStyle = color[status.step] || "#9900FF"; // 绘制颜色
  115. this.context.lineWidth = 12;
  116. this.context.arc( // 绘制圆
  117. this.centerX,
  118. this.centerY,
  119. 100,
  120. -Math.PI / 2,
  121. -Math.PI / 2 + status.percent * this.rad,
  122. false
  123. );
  124. this.context.stroke();
  125. this.context.restore();
  126. },
  127. whiteCircle() { // 底层白圈
  128. this.context.save();
  129. this.context.beginPath();
  130. this.context.strokeStyle = "#383a41";
  131. this.context.lineWidth = 12;
  132. this.context.arc(this.centerX, this.centerY, 100, 0, Math.PI * 2, false);
  133. this.context.stroke();
  134. this.context.closePath();
  135. this.context.restore();
  136. }
  137. }
  138. };
  139. </script>

添加重试与等待机制

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

  1. const getMp3Data: any = async (text: string, opts: any = {}) => {
  2. const textArr = splitText(text)
  3. return Promise.all(
  4. textArr.map(async chunk => {
  5. const { data } = await client.text2audio(chunk, opts)
  6. return data
  7. })
  8. ).catch(() => getMp3Data(text, opts))
  9. }

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

  1. await timer(TTS_WAIT).toPromise()

忽略错误

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

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

download