dsh-tingdong 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/lib/importer.js +1 -1
- package/lib/pcm.js +6 -3
- package/lib/server.js +16 -5
- package/lib/tracks.js +8 -3
- package/lib/tts-worker.js +1 -1
- package/package.json +1 -1
- package/plugin/index.js +14 -0
- package/plugin/manage.js +129 -0
- package/web/panel.css +7 -0
- package/web/panel.html +1 -0
- package/web/panel.js +80 -4
package/README.md
CHANGED
|
@@ -20,9 +20,9 @@
|
|
|
20
20
|
| 连续听 | 一句接一句不停地播,英文 / 中文字幕分别开关;最后关掉字幕整篇听 |
|
|
21
21
|
| 词汇 | 全篇关键术语、每句的生词、你查过的词,点一下听原句 |
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
**导出**(每课的「导出」页签):
|
|
24
24
|
|
|
25
|
-
-
|
|
25
|
+
- **中文配音(音频)**:可以下载 **MP3** 或无损 **FLAC**(44.1 kHz / 16 位,和合成出来的原始音频同源,不是 MP3 转的)。把英文换成中文配音,只有中文、没有背景音,每句中文从对应英文开始的时刻开始说,总长和原音频一样(也可以对齐「原声·去空白」)。比空档长的句子自动读快一点(最快 1.6 倍),不会挤到下一句。两种语音可选:
|
|
26
26
|
- **本地 Kokoro**:离线,55 个普通话女声、45 个普通话男声,每个都能先试听;第一次用时下载约 147 MB 模型
|
|
27
27
|
- **微软 Edge**:晓晓、云希、云扬等 14 种(含台湾、粤语、方言),要联网。插件调用开源工具 [edge-tts](https://github.com/rany2/edge-tts),点「安装 Edge 语音组件」会装进插件自己的 Python 虚拟环境(需要电脑上有 Python 3)。文字会发到微软的服务器;这不是微软公开的正式接口,可能失效
|
|
28
28
|
- **文字**:双语 / 英文 / 中文字幕(SRT)、双语歌词 LRC(Walkman 等播放器滚动显示)、**录音稿**(每句的开始时间、英文时长、可用时长和中英对照,照着自己录中文配音)、表格 CSV。时间可按原音频或「原声·去空白」
|
|
@@ -48,6 +48,8 @@
|
|
|
48
48
|
|
|
49
49
|
在 DSH 对话里也可以直接说:「把 /Volumes/…/CD4 导入听懂」「我这课哪些句子总听不懂?给我讲讲」——插件给 AI 注册了 `tingdong_import`、`tingdong_lessons`、`tingdong_sentences` 三个工具。
|
|
50
50
|
|
|
51
|
+
**更新 / 卸载**:首页标题旁显示版本号,打开时自动查 npm 上有没有新版本;有就出现「更新到 vX」,点一下由 DSH 的插件管理器装好(和插件市场同一套机制),重启 DSH 生效。「卸载」只移除插件,课程和学习进度留在 `~/.dsh/dsh-tingdong`。
|
|
52
|
+
|
|
51
53
|
## 安装
|
|
52
54
|
|
|
53
55
|
需要 [ffmpeg](https://ffmpeg.org)(解码 MP3):Mac 上 `brew install ffmpeg`。
|
package/lib/importer.js
CHANGED
|
@@ -62,7 +62,7 @@ export class Importer {
|
|
|
62
62
|
/** 给界面看的任务列表 */
|
|
63
63
|
list() {
|
|
64
64
|
return this.jobs.map(j => ({
|
|
65
|
-
id: j.id, lessonId: j.lessonId, title: j.title, kind: j.kind, state: j.state, stage: j.stage,
|
|
65
|
+
id: j.id, lessonId: j.lessonId, title: j.title, kind: j.kind, track: j.track, state: j.state, stage: j.stage,
|
|
66
66
|
done: j.done, total: j.total, message: j.message, error: j.error, startedAt: j.startedAt, finishedAt: j.finishedAt,
|
|
67
67
|
}))
|
|
68
68
|
}
|
package/lib/pcm.js
CHANGED
|
@@ -50,12 +50,15 @@ export function trimSilence(samples, rate = RATE, keep = 0.05) {
|
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
52
|
* 一边收句子一边编码:write(i, samples) 按顺序调用,finish() 返回时间表
|
|
53
|
-
* @param {object} o { ffmpeg, out, gap=0.7, bitrate='64k', signal }
|
|
53
|
+
* @param {object} o { ffmpeg, out, flac?(同时出一份 FLAC 的路径), gap=0.7, bitrate='64k', signal }
|
|
54
54
|
*/
|
|
55
55
|
export function createEncoder(o) {
|
|
56
56
|
const gap = o.gap ?? 0.7
|
|
57
|
-
const
|
|
58
|
-
'-codec:a', 'libmp3lame', '-b:a', o.bitrate || '64k', o.out]
|
|
57
|
+
const args = ['-hide_banner', '-nostdin', '-y', '-f', 'f32le', '-ar', String(RATE), '-ac', '1', '-i', 'pipe:0',
|
|
58
|
+
'-codec:a', 'libmp3lame', '-b:a', o.bitrate || '64k', o.out]
|
|
59
|
+
// 同时出一份无损 FLAC(44.1 kHz / 16 位,和 CD 一样,播放器都认):从同一份原始音频编码,不是拿 MP3 转的
|
|
60
|
+
if (o.flac) args.push('-ar', '44100', '-sample_fmt', 's16', '-codec:a', 'flac', o.flac)
|
|
61
|
+
const ff = spawn(o.ffmpeg, args, { stdio: ['pipe', 'ignore', 'pipe'] })
|
|
59
62
|
let stderr = ''
|
|
60
63
|
ff.stderr.on('data', d => { stderr = (stderr + d).slice(-3000) })
|
|
61
64
|
let broken = null
|
package/lib/server.js
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
// POST <prefix>/lookup {id, i, word} 点词查意思
|
|
22
22
|
// POST <prefix>/drill {id, track, repeat, pause, scope, from} 生成「磨耳朵」音频
|
|
23
23
|
// GET <prefix>/vps | POST /vps/detect {host} | /vps/config {...} | /vps/send {ids} 发送到 VPS
|
|
24
|
+
// GET <prefix>/update[?force=1] · POST /update · POST /uninstall 插件自己的版本、更新、卸载(只在 DSH 里)
|
|
24
25
|
// GET <prefix>/export?id=&format=&align= 导出文字:字幕 SRT、歌词 LRC、录音稿、表格
|
|
25
26
|
// POST <prefix>/voice-sample {engine, voice} 试听一个中文音色 · POST /edge/install 装 / 更新 Edge 语音组件
|
|
26
27
|
//
|
|
@@ -33,7 +34,7 @@ import { pipeline } from 'node:stream/promises'
|
|
|
33
34
|
import { findModels } from './models.js'
|
|
34
35
|
import { findFfmpeg, uploadsDir } from './paths.js'
|
|
35
36
|
import { AUDIO_EXT } from './importer.js'
|
|
36
|
-
import { listTracks, trackAudio, TRACKS, zhSample } from './tracks.js'
|
|
37
|
+
import { listTracks, trackAudio, trackFlac, TRACKS, zhSample } from './tracks.js'
|
|
37
38
|
import { buildDrill, DRILL_RE } from './drill.js'
|
|
38
39
|
import { explainSentences, lookupWord } from './lookup.js'
|
|
39
40
|
import { today } from './today.js'
|
|
@@ -75,14 +76,14 @@ function safePost(req) {
|
|
|
75
76
|
try { return new URL(origin).host === req.headers.host } catch { return false }
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
async function serveAudio(req, res, file, filename) {
|
|
79
|
+
async function serveAudio(req, res, file, filename, type = 'audio/mpeg') {
|
|
79
80
|
let st
|
|
80
81
|
try { st = await stat(file) } catch { return send(res, 404, { error: '这条声音还没有生成' }) }
|
|
81
82
|
const size = st.size
|
|
82
83
|
const m = /^bytes=(\d*)-(\d*)$/.exec(String(req.headers.range || ''))
|
|
83
84
|
// 重新生成后文件会变:用修改时间做 ETag,别让浏览器拿旧的缓存配新的时间表
|
|
84
|
-
const head = { 'content-type':
|
|
85
|
-
if (filename) head['content-disposition'] = `attachment; filename="audio.
|
|
85
|
+
const head = { 'content-type': type, 'accept-ranges': 'bytes', 'cache-control': 'private, no-cache', etag: `"${st.size}-${Math.round(st.mtimeMs)}"` }
|
|
86
|
+
if (filename) head['content-disposition'] = `attachment; filename="audio${filename.slice(filename.lastIndexOf('.'))}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
|
86
87
|
if (!m || (!m[1] && !m[2])) {
|
|
87
88
|
res.writeHead(200, { ...head, 'content-length': size })
|
|
88
89
|
if (req.method === 'HEAD') return res.end()
|
|
@@ -142,13 +143,17 @@ export function createHandler(o) {
|
|
|
142
143
|
const id = checkId(q.get('id'))
|
|
143
144
|
const track = q.get('track') || 'full'
|
|
144
145
|
if (!TRACKS[track] && !DRILL_RE.test(track)) return send(res, 400, { error: '没有这种声音' })
|
|
146
|
+
// fmt=flac:无损版(只有中文配音生成时同时出了 FLAC)
|
|
147
|
+
const flac = q.get('fmt') === 'flac'
|
|
148
|
+
if (flac && track !== 'zh') return send(res, 400, { error: '只有中文配音有 FLAC' })
|
|
145
149
|
let filename = ''
|
|
146
150
|
if (q.get('download')) {
|
|
147
151
|
const l = await getLesson(id)
|
|
148
152
|
const meta = TRACKS[track] ? (await listTracks(id))[track] : null
|
|
149
153
|
const label = TRACKS[track] ? TRACKS[track].label + (meta && meta.voiceLabel ? `(${meta.voiceLabel})` : '') : '磨耳朵'
|
|
150
|
-
filename = `${(l && (l.titleZh || l.title)) || id} - ${label}
|
|
154
|
+
filename = `${(l && (l.titleZh || l.title)) || id} - ${label}.${flac ? 'flac' : 'mp3'}`.replace(/[\\/:*?"<>|]/g, '_')
|
|
151
155
|
}
|
|
156
|
+
if (flac) return serveAudio(req, res, trackFlac(id, track), filename, 'audio/flac')
|
|
152
157
|
return serveAudio(req, res, trackAudio(id, track), filename)
|
|
153
158
|
}
|
|
154
159
|
if (path === '/voices') {
|
|
@@ -175,6 +180,10 @@ export function createHandler(o) {
|
|
|
175
180
|
return res.end(out.text)
|
|
176
181
|
}
|
|
177
182
|
if (path === '/formats') return send(res, 200, { formats: FORMATS })
|
|
183
|
+
if (path === '/update') {
|
|
184
|
+
if (!o.manage) return send(res, 200, { managed: false })
|
|
185
|
+
return send(res, 200, { managed: true, ...(await o.manage.info(q.get('force') === '1')) })
|
|
186
|
+
}
|
|
178
187
|
if (path === '/jobs') return send(res, 200, { jobs: o.importer.list() })
|
|
179
188
|
if (path === '/today') return send(res, 200, await today({ tz: Number(q.get('tz')) || 0 }))
|
|
180
189
|
if (path === '/vps') {
|
|
@@ -265,6 +274,8 @@ export function createHandler(o) {
|
|
|
265
274
|
const file = await zhSample(String(body.voice || ''), { ffmpeg: opts().ffmpeg, engine: body.engine === 'edge' ? 'edge' : 'kokoro' })
|
|
266
275
|
return serveAudio(req, res, file)
|
|
267
276
|
}
|
|
277
|
+
if (path === '/update' && o.manage) return send(res, 200, await o.manage.update())
|
|
278
|
+
if (path === '/uninstall' && o.manage) return send(res, 200, await o.manage.uninstall())
|
|
268
279
|
if (path === '/edge/install') return send(res, 200, { ok: true, ...(await installEdge()) })
|
|
269
280
|
if (path === '/vps/detect') return send(res, 200, { ok: true, ...(await detect(String(body.host || ''))) })
|
|
270
281
|
if (path === '/vps/config') {
|
package/lib/tracks.js
CHANGED
|
@@ -33,6 +33,8 @@ export const TRACK_NAMES = Object.keys(TRACKS)
|
|
|
33
33
|
|
|
34
34
|
const tracksDir = id => join(lessonDir(id), 'tracks')
|
|
35
35
|
export const trackAudio = (id, name) => (name === 'full' ? join(lessonDir(id), 'audio.mp3') : join(tracksDir(id), `${name}.mp3`))
|
|
36
|
+
/** 无损版(目前只有中文配音有) */
|
|
37
|
+
export const trackFlac = (id, name) => join(tracksDir(id), `${name}.flac`)
|
|
36
38
|
|
|
37
39
|
/** 已生成的声音轨(不含 full) */
|
|
38
40
|
export async function listTracks(id) {
|
|
@@ -206,11 +208,14 @@ async function buildZh(lesson, o) {
|
|
|
206
208
|
? await ensureKokoroZh({ signal: o.signal, onProgress: (g, t) => o.onStage && o.onStage('下载中文语音模型', Math.round(g / 1048576), Math.round(t / 1048576), 'MB') })
|
|
207
209
|
: undefined
|
|
208
210
|
const out = join(tracksDir(lesson.id), 'zh.part.mp3')
|
|
209
|
-
const
|
|
211
|
+
const flac = join(tracksDir(lesson.id), 'zh.part.flac')
|
|
212
|
+
const args = { flac, engine: `${engine}-zh`, sid: voice.sid, voice: voice.id, speed: o.speed || 1, sentences, total, kokoroDir, ffmpeg, out, threads: o.threads || 2, bitrate: '48k' }
|
|
210
213
|
o.onStage && o.onStage(`中文对译(${voice.label})`, 0, sentences.length, '句')
|
|
211
|
-
|
|
214
|
+
let res
|
|
215
|
+
try { res = await runWorker(args, o, `中文对译(${voice.label})`) } catch (e) { await rm(flac, { force: true }); throw e }
|
|
212
216
|
await rename(out, trackAudio(lesson.id, 'zh'))
|
|
213
|
-
|
|
217
|
+
await rename(flac, trackFlac(lesson.id, 'zh'))
|
|
218
|
+
return { flac: true, items: res.items, duration: res.duration, engine, voice: voice.id, voiceLabel: `${engine === 'edge' ? 'Edge ' : ''}${voice.label}`, speed: args.speed, align, squeezed: res.squeezed || 0 }
|
|
214
219
|
}
|
|
215
220
|
|
|
216
221
|
/** 试听一个中文音色:同一句话,生成一次缓存起来 */
|
package/lib/tts-worker.js
CHANGED
|
@@ -124,7 +124,7 @@ function sayEngine(opt) {
|
|
|
124
124
|
|
|
125
125
|
/** 按时间表放(中文对译):每句放在 start;比空档长就读快一点,最快 1.6 倍 */
|
|
126
126
|
async function timeline(opt, speak) {
|
|
127
|
-
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, gap: 0, bitrate: opt.bitrate })
|
|
127
|
+
const enc = createEncoder({ ffmpeg: opt.ffmpeg, out: opt.out, flac: opt.flac, gap: 0, bitrate: opt.bitrate })
|
|
128
128
|
const list = (opt.sentences || []).map(s => ({ ...s, text: tidyZh(s.text) })).filter(s => s.text).sort((a, b) => a.start - b.start)
|
|
129
129
|
const base = opt.speed || 1
|
|
130
130
|
let squeezed = 0
|
package/package.json
CHANGED
package/plugin/index.js
CHANGED
|
@@ -21,6 +21,7 @@ import { Importer } from '../lib/importer.js'
|
|
|
21
21
|
import { dshLlm } from '../lib/llm.js'
|
|
22
22
|
import { createHandler } from '../lib/server.js'
|
|
23
23
|
import { registerTools } from './tools.js'
|
|
24
|
+
import { createManager } from './manage.js'
|
|
24
25
|
|
|
25
26
|
const PKG_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
26
27
|
export const PREFIX = '/api-dsh-tingdong'
|
|
@@ -77,7 +78,20 @@ export function apply(ctx, config = {}) {
|
|
|
77
78
|
if (typeof ctx.effect === 'function') ctx.effect(() => stopAll)
|
|
78
79
|
else if (typeof ctx.on === 'function') ctx.on('dispose', stopAll)
|
|
79
80
|
|
|
81
|
+
// DSH 自己的插件管理器(更新 / 卸载按钮用;老版本 DSH 没有)
|
|
82
|
+
let pluginManager
|
|
83
|
+
ctx.inject(['pluginManager'], (c) => {
|
|
84
|
+
c.effect(() => {
|
|
85
|
+
pluginManager = c.pluginManager || (c.get && c.get('pluginManager'))
|
|
86
|
+
return () => { pluginManager = undefined }
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
let profile
|
|
90
|
+
try { profile = ctx.get && ctx.get('profileContext') && ctx.get('profileContext').name } catch { profile = undefined }
|
|
91
|
+
const manage = createManager({ pkgRoot: PKG_ROOT, pluginManager: () => pluginManager, profile: () => profile })
|
|
92
|
+
|
|
80
93
|
const handle = createHandler({
|
|
94
|
+
manage,
|
|
81
95
|
prefix: PREFIX,
|
|
82
96
|
pkgRoot: PKG_ROOT,
|
|
83
97
|
importer,
|
package/plugin/manage.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// plugin/manage.js — 插件自己的版本、更新、卸载(首页标题旁的按钮)
|
|
2
|
+
//
|
|
3
|
+
// 更新、卸载交给 DSH 自己的插件管理器(pluginManager.installBundle / removeBundle,和 DSH「插件」页同一套:
|
|
4
|
+
// 带锁跑 pnpm、失败自动还原)。写法照 dsh-pick。拿不到插件管理器(老版本 DSH)或它拒绝时,
|
|
5
|
+
// 给出用户可以自己在终端执行的命令。装完要重启 DSH 才生效。
|
|
6
|
+
|
|
7
|
+
import { readFile } from 'node:fs/promises'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
|
|
10
|
+
export const PACKAGE = 'dsh-tingdong'
|
|
11
|
+
// npm 官方源连不上(国内常见)就问镜像
|
|
12
|
+
const REGISTRIES = ['https://registry.npmjs.org', 'https://registry.npmmirror.com']
|
|
13
|
+
const VERSION_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/
|
|
14
|
+
|
|
15
|
+
/** a 比 b 新返回正数(x.y.z,预发布版比同号正式版旧) */
|
|
16
|
+
export function compareVersions(a, b) {
|
|
17
|
+
const parse = v => {
|
|
18
|
+
const [core, pre = ''] = String(v || '').replace(/^v/, '').split('-', 2)
|
|
19
|
+
return { nums: core.split('.').map(n => parseInt(n, 10) || 0), pre }
|
|
20
|
+
}
|
|
21
|
+
const x = parse(a), y = parse(b)
|
|
22
|
+
for (let i = 0; i < 3; i++) {
|
|
23
|
+
const d = (x.nums[i] || 0) - (y.nums[i] || 0)
|
|
24
|
+
if (d) return d
|
|
25
|
+
}
|
|
26
|
+
if (x.pre === y.pre) return 0
|
|
27
|
+
if (!x.pre) return 1
|
|
28
|
+
if (!y.pre) return -1
|
|
29
|
+
return x.pre < y.pre ? -1 : 1
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** 插件管理器的失败结果 → 错误码 / 一行人话 */
|
|
33
|
+
function managerCode(result) {
|
|
34
|
+
if (!(result && (result.application === 'failed' || result.application === 'cancelled' || result.error))) return ''
|
|
35
|
+
return (result.error && result.error.code) || result.application || 'failed'
|
|
36
|
+
}
|
|
37
|
+
function managerError(result) {
|
|
38
|
+
const code = managerCode(result)
|
|
39
|
+
if (!code) return ''
|
|
40
|
+
const detail = String((result.error && result.error.diagnostic) || (result.packageResult && result.packageResult.output) || '')
|
|
41
|
+
.trim().split('\n').slice(-3).join(' ').slice(-300)
|
|
42
|
+
return detail ? `${code}:${detail}` : code
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const RESTART = 'DSH 要重启才用上新版本:电脑上完全退出 DSH(Mac 按 Cmd+Q,不是关窗口)再打开;VPS 上在「设置 → VPS 部署」里重启。'
|
|
46
|
+
const ALREADY = '这个版本的 DSH 不能在运行中升级已装的插件。完全退出 DSH(Mac 上按 Cmd+Q)后在终端执行下面的命令,再打开 DSH。'
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} o
|
|
50
|
+
* @param {string} o.pkgRoot 本包根目录
|
|
51
|
+
* @param {() => any} o.pluginManager 拿 DSH 的插件管理器(可能没有)
|
|
52
|
+
* @param {() => string|undefined} o.profile 当前 profile 名(拼命令用)
|
|
53
|
+
*/
|
|
54
|
+
export function createManager(o) {
|
|
55
|
+
let running = ''
|
|
56
|
+
const readPkg = async () => { try { return JSON.parse(await readFile(join(o.pkgRoot, 'package.json'), 'utf8')) } catch { return {} } }
|
|
57
|
+
readPkg().then(p => { running = p.version || '' })
|
|
58
|
+
|
|
59
|
+
let cache = null
|
|
60
|
+
async function latest(force) {
|
|
61
|
+
if (!force && cache && Date.now() - cache.at < 10 * 60 * 1000) return cache.version
|
|
62
|
+
for (const r of REGISTRIES) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch(`${r}/${PACKAGE}/latest`, { signal: AbortSignal.timeout(6000) })
|
|
65
|
+
if (!res.ok) continue
|
|
66
|
+
const { version } = await res.json()
|
|
67
|
+
if (VERSION_RE.test(String(version || ''))) { cache = { version, at: Date.now() }; return version }
|
|
68
|
+
} catch { /* 换下一个源 */ }
|
|
69
|
+
}
|
|
70
|
+
return ''
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const command = args => {
|
|
74
|
+
const p = o.profile()
|
|
75
|
+
return /^[A-Za-z0-9._-]+$/.test(p || '') ? `dsh plugin --profile ${p} ${args}` : `dsh plugin ${args}`
|
|
76
|
+
}
|
|
77
|
+
const pm = () => { try { return o.pluginManager() } catch { return null } }
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
/** 版本信息:正在运行的、磁盘上装好的、npm 上最新的 */
|
|
81
|
+
async info(force) {
|
|
82
|
+
const [pkg, newest] = await Promise.all([readPkg(), latest(force)])
|
|
83
|
+
const installed = pkg.version || ''
|
|
84
|
+
const current = compareVersions(installed, running) > 0 ? installed : running
|
|
85
|
+
const m = pm()
|
|
86
|
+
return {
|
|
87
|
+
running,
|
|
88
|
+
restartPending: installed && running && compareVersions(installed, running) > 0 ? installed : '',
|
|
89
|
+
latest: newest,
|
|
90
|
+
available: Boolean(newest) && compareVersions(newest, current) > 0,
|
|
91
|
+
canInstall: Boolean(m && typeof m.installBundle === 'function'),
|
|
92
|
+
canRemove: Boolean(m && typeof m.removeBundle === 'function'),
|
|
93
|
+
command: command(`add ${PACKAGE}@${newest || 'latest'}`),
|
|
94
|
+
removeCommand: command(`remove ${PACKAGE}`),
|
|
95
|
+
restartHint: RESTART,
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
async update() {
|
|
99
|
+
const newest = await latest(true)
|
|
100
|
+
const cmd = command(`add ${PACKAGE}@${newest || 'latest'}`)
|
|
101
|
+
if (!newest) return { ok: false, message: '查不到 npm 上的最新版本,检查网络后再试', command: cmd }
|
|
102
|
+
if (compareVersions(newest, running) <= 0) return { ok: false, message: `已经是最新版 v${running}` }
|
|
103
|
+
const m = pm()
|
|
104
|
+
if (!m || typeof m.installBundle !== 'function') return { ok: false, message: '这个版本的 DSH 不能在这里装插件,请在终端执行命令', command: cmd }
|
|
105
|
+
try {
|
|
106
|
+
const result = await m.installBundle(`${PACKAGE}@${newest}`, { enabled: true })
|
|
107
|
+
if (managerCode(result) === 'already-installed') return { ok: false, message: ALREADY, command: cmd }
|
|
108
|
+
const err = managerError(result)
|
|
109
|
+
if (err) return { ok: false, message: `更新失败(${err})`, command: cmd }
|
|
110
|
+
return { ok: true, version: newest, message: RESTART }
|
|
111
|
+
} catch (e) {
|
|
112
|
+
if (e && e.code === 'already-installed') return { ok: false, message: ALREADY, command: cmd }
|
|
113
|
+
return { ok: false, message: `更新失败:${e.message}`, command: cmd }
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
async uninstall() {
|
|
117
|
+
const cmd = command(`remove ${PACKAGE}`)
|
|
118
|
+
const m = pm()
|
|
119
|
+
if (!m || typeof m.removeBundle !== 'function') return { ok: false, message: '这里没法直接卸载:完全退出 DSH 后在终端执行命令', command: cmd }
|
|
120
|
+
try {
|
|
121
|
+
const err = managerError(await m.removeBundle(PACKAGE))
|
|
122
|
+
if (err) return { ok: false, message: `卸载失败(${err}),可完全退出 DSH 后在终端执行命令`, command: cmd }
|
|
123
|
+
return { ok: true, message: '已卸载,重启 DSH 后彻底移除。课程和学习进度还留在 ~/.dsh/dsh-tingdong,重新装上就回来。' }
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { ok: false, message: `卸载失败:${e.message}`, command: cmd }
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
}
|
package/web/panel.css
CHANGED
|
@@ -301,3 +301,10 @@ textarea:focus { border-color: var(--accent); }
|
|
|
301
301
|
/* —— 导出 —— */
|
|
302
302
|
.sub-h { font-size: 13px; font-weight: 600; margin: 4px 0 2px; }
|
|
303
303
|
.row.wrap { flex-wrap: wrap; }
|
|
304
|
+
|
|
305
|
+
/* —— 标题旁的版本、更新、卸载 —— */
|
|
306
|
+
.manage { display: inline-flex; align-items: center; gap: 6px; margin-left: 4px; flex-wrap: wrap; }
|
|
307
|
+
.manage .ver { font-variant-numeric: tabular-nums; }
|
|
308
|
+
.btn.small { padding: 2px 9px; font-size: 12px; line-height: 18px; }
|
|
309
|
+
code.cmd { font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; user-select: all; overflow-wrap: anywhere; }
|
|
310
|
+
@media (max-width: 640px) { .btn.small { padding: 4px 10px; font-size: 13px; } .top { flex-wrap: wrap; } }
|
package/web/panel.html
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
<header class="top">
|
|
16
16
|
<button class="icon-btn" id="back" hidden title="返回课程列表" aria-label="返回">‹</button>
|
|
17
17
|
<div class="brand" id="brand">听懂</div>
|
|
18
|
+
<span class="manage" id="manage" hidden></span>
|
|
18
19
|
<div class="spacer"></div>
|
|
19
20
|
<a class="link" id="popout" href="panel" target="_blank" rel="noopener" hidden>新窗口打开</a>
|
|
20
21
|
</header>
|
package/web/panel.js
CHANGED
|
@@ -98,9 +98,11 @@ async function render(soft = false) {
|
|
|
98
98
|
const r = route()
|
|
99
99
|
$('#back').hidden = r.view === 'library'
|
|
100
100
|
if (!soft) { keys.handler = null; loop.on = false; clearMediaControls() }
|
|
101
|
+
$('#manage').hidden = r.view !== 'library'
|
|
101
102
|
if (r.view === 'library') {
|
|
102
103
|
if (!soft) player.stop()
|
|
103
104
|
$('#brand').textContent = '听懂 · 英文听力'
|
|
105
|
+
if (!soft) renderManage()
|
|
104
106
|
rendered = 'library'
|
|
105
107
|
renderLibrary()
|
|
106
108
|
// 从课程页回来:进度变了,后台重新拉一次课程列表
|
|
@@ -359,7 +361,7 @@ async function copy(text) {
|
|
|
359
361
|
|
|
360
362
|
// ———————————————————— 一节课 ————————————————————
|
|
361
363
|
|
|
362
|
-
const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇']]
|
|
364
|
+
const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇'], ['export', '导出']]
|
|
363
365
|
|
|
364
366
|
function renderLesson(tab) {
|
|
365
367
|
const l = S.cur.lesson
|
|
@@ -381,7 +383,7 @@ function renderLesson(tab) {
|
|
|
381
383
|
bindManage(pane, l)
|
|
382
384
|
return
|
|
383
385
|
}
|
|
384
|
-
;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords }[tab] || paneOverview)(pane, l, st)
|
|
386
|
+
;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords, export: paneExport }[tab] || paneOverview)(pane, l, st)
|
|
385
387
|
}
|
|
386
388
|
|
|
387
389
|
/** 课程页上方的「声音」选择条 */
|
|
@@ -460,7 +462,10 @@ function paneOverview(pane, l, st) {
|
|
|
460
462
|
${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
|
|
461
463
|
</div>
|
|
462
464
|
${voicesCard(l)}
|
|
463
|
-
|
|
465
|
+
<div class="card row">
|
|
466
|
+
<div class="grow"><b>导出</b><div class="faint">中文配音(MP3)、双语字幕、LRC 歌词、录音稿</div></div>
|
|
467
|
+
<button class="btn" id="goExport">去导出 ›</button>
|
|
468
|
+
</div>
|
|
464
469
|
<div class="card">
|
|
465
470
|
<h3>怎么练到「一听就懂」</h3>
|
|
466
471
|
<ol class="steps">
|
|
@@ -474,7 +479,7 @@ function paneOverview(pane, l, st) {
|
|
|
474
479
|
</div>
|
|
475
480
|
<div class="card"><h3>管理</h3>${manageButtons(l)}</div>`
|
|
476
481
|
bindVoices(pane, l)
|
|
477
|
-
|
|
482
|
+
$('#goExport').onclick = () => go(`#/l/${l.id}/export`)
|
|
478
483
|
$('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
|
|
479
484
|
$('#goReview').onclick = () => go(`#/l/${l.id}/review`)
|
|
480
485
|
$('#goDrill').onclick = () => go(`#/l/${l.id}/drill`)
|
|
@@ -564,6 +569,12 @@ let zhAudio = null // 试听用的独立播放器,不打扰课程的播放器
|
|
|
564
569
|
function stopZhAudio() { if (zhAudio) { zhAudio.pause(); zhAudio = null } }
|
|
565
570
|
window.addEventListener('hashchange', stopZhAudio)
|
|
566
571
|
|
|
572
|
+
/** 「导出」页签 */
|
|
573
|
+
function paneExport(pane, l) {
|
|
574
|
+
pane.innerHTML = zhCard(l)
|
|
575
|
+
bindZh(pane, l)
|
|
576
|
+
}
|
|
577
|
+
|
|
567
578
|
const TEXT_FORMATS = [['srt-bi', '双语字幕'], ['srt-en', '英文字幕'], ['srt-zh', '中文字幕'], ['lrc', '歌词 LRC'], ['script', '录音稿'], ['csv', '表格']]
|
|
568
579
|
|
|
569
580
|
function zhCard(l) {
|
|
@@ -590,6 +601,7 @@ function zhCard(l) {
|
|
|
590
601
|
<div class="row" style="margin-top:6px">
|
|
591
602
|
<button class="btn" id="zhPlay">▶ 试听</button>
|
|
592
603
|
<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&download=1" download>下载 MP3</a>
|
|
604
|
+
${meta.flac ? `<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&fmt=flac&download=1" download>下载 FLAC(无损)</a>` : '<span class="faint">要 FLAC:重新生成一次</span>'}
|
|
593
605
|
</div>` : ''}
|
|
594
606
|
${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
|
|
595
607
|
<div class="form">
|
|
@@ -687,6 +699,70 @@ function bindZh(root, l) {
|
|
|
687
699
|
}
|
|
688
700
|
}
|
|
689
701
|
|
|
702
|
+
// ———————————————————— 版本、更新、卸载(首页标题旁) ————————————————————
|
|
703
|
+
// 打开首页时自动去 npm 查新版本;有就亮出「更新到 vX」,点一下交给 DSH 的插件管理器装好。
|
|
704
|
+
let manageInfo = null
|
|
705
|
+
let managePhase = '' // updating | updated | removing | removed
|
|
706
|
+
async function renderManage(force = false) {
|
|
707
|
+
const box = $('#manage')
|
|
708
|
+
if (!manageInfo || force) {
|
|
709
|
+
try { manageInfo = await api(`update${force ? '?force=1' : ''}`) } catch { manageInfo = null }
|
|
710
|
+
}
|
|
711
|
+
const m = manageInfo
|
|
712
|
+
if (!m || !m.managed) { box.hidden = true; return }
|
|
713
|
+
box.hidden = route().view !== 'library'
|
|
714
|
+
let upd
|
|
715
|
+
if (managePhase === 'updating') upd = '<button class="btn small" disabled>正在更新…</button>'
|
|
716
|
+
else if (managePhase === 'updated' || m.restartPending) upd = `<button class="btn small" id="mRestart">v${esc(m.restartPending || m.latest)} 已装好,重启 DSH 生效</button>`
|
|
717
|
+
else if (m.available) upd = `<button class="btn small primary" id="mUpdate">更新到 v${esc(m.latest)}</button>`
|
|
718
|
+
else upd = `<button class="btn small ghost" id="mCheck" title="点一下重新检查">${m.latest ? '已是最新' : '检查更新'}</button>`
|
|
719
|
+
box.innerHTML = managePhase === 'removed' ? '<span class="faint">已卸载,重启 DSH 后移除</span>'
|
|
720
|
+
: `<span class="faint ver">v${esc(m.running)}</span>${upd}<button class="btn small ghost danger" id="mRemove" ${managePhase ? 'disabled' : ''}>卸载</button>`
|
|
721
|
+
const on = (sel, fn) => { const el = box.querySelector(sel); if (el) el.onclick = fn }
|
|
722
|
+
on('#mCheck', async () => { box.querySelector('#mCheck').textContent = '检查中…'; await renderManage(true); if (!manageInfo.available) toast(manageInfo.latest ? `已是最新版 v${manageInfo.running}` : '查不到 npm 上的版本,检查网络') })
|
|
723
|
+
on('#mRestart', () => manageNote({ message: m.restartHint }))
|
|
724
|
+
on('#mUpdate', async () => {
|
|
725
|
+
managePhase = 'updating'
|
|
726
|
+
renderManage()
|
|
727
|
+
try {
|
|
728
|
+
const r = await api('update', {})
|
|
729
|
+
managePhase = r.ok ? 'updated' : ''
|
|
730
|
+
if (r.ok) manageInfo.latest = r.version
|
|
731
|
+
manageNote(r)
|
|
732
|
+
} catch (e) { managePhase = ''; toast(e.message) }
|
|
733
|
+
renderManage()
|
|
734
|
+
})
|
|
735
|
+
on('#mRemove', async () => {
|
|
736
|
+
if (!confirm('从 DSH 卸载「听懂」插件?\n课程和学习进度会留在电脑上(~/.dsh/dsh-tingdong),重新装上就回来。')) return
|
|
737
|
+
managePhase = 'removing'
|
|
738
|
+
renderManage()
|
|
739
|
+
try {
|
|
740
|
+
const r = await api('uninstall', {})
|
|
741
|
+
managePhase = r.ok ? 'removed' : ''
|
|
742
|
+
manageNote(r)
|
|
743
|
+
} catch {
|
|
744
|
+
// 卸下插件时它自己的接口也随之消失,请求多半就是这么断的
|
|
745
|
+
managePhase = 'removed'
|
|
746
|
+
}
|
|
747
|
+
renderManage()
|
|
748
|
+
})
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/** 更新 / 卸载的结果:一段说明,失败时附上可以自己在终端执行的命令 */
|
|
752
|
+
function manageNote(r) {
|
|
753
|
+
const sheet = document.createElement('div')
|
|
754
|
+
sheet.className = 'sheet-wrap'
|
|
755
|
+
sheet.innerHTML = `<div class="sheet">
|
|
756
|
+
<div class="row"><b class="grow">${r.ok ? '完成' : r.ok === false ? '没有完成' : '提示'}</b><button class="icon-btn" data-close aria-label="关闭">×</button></div>
|
|
757
|
+
<div style="margin:8px 0;line-height:1.7">${esc(r.message || '')}</div>
|
|
758
|
+
${r.command ? `<div class="row"><code class="cmd grow">${esc(r.command)}</code><button class="btn" id="copyCmd">复制</button></div>` : ''}
|
|
759
|
+
</div>`
|
|
760
|
+
document.body.appendChild(sheet)
|
|
761
|
+
sheet.addEventListener('click', e => { if (e.target === sheet || e.target.closest('[data-close]')) sheet.remove() })
|
|
762
|
+
const c = sheet.querySelector('#copyCmd')
|
|
763
|
+
if (c) c.onclick = () => copy(r.command)
|
|
764
|
+
}
|
|
765
|
+
|
|
690
766
|
// ———————————————————— 启动 ————————————————————
|
|
691
767
|
|
|
692
768
|
;(async () => {
|