dsh-tingdong 0.3.0 → 0.4.0
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 +17 -5
- package/bin/dsh-tingdong.js +28 -0
- package/lib/doc.js +132 -0
- package/lib/edge.js +19 -0
- package/lib/importer.js +54 -5
- package/lib/pcm.js +6 -3
- package/lib/server.js +35 -8
- package/lib/store.js +2 -0
- package/lib/tracks.js +46 -4
- package/lib/translate.js +8 -6
- package/lib/tts-worker.js +26 -1
- package/package.json +2 -2
- package/plugin/client.js +102 -7
- package/plugin/index.js +14 -0
- package/plugin/manage.js +129 -0
- package/plugin/tools.js +29 -0
- package/web/core.js +3 -1
- package/web/listen.js +3 -3
- package/web/panel.css +32 -0
- package/web/panel.html +1 -0
- package/web/panel.js +187 -26
package/web/panel.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { stats } from './srs.js'
|
|
8
8
|
import {
|
|
9
|
-
$, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, keys, loadAudio, loadLesson, loop, nav, player,
|
|
9
|
+
$, api, app, clearMediaControls, curTrack, esc, fmt, fmtDur, getPos, isDoc, keys, loadAudio, loadLesson, loop, nav, player,
|
|
10
10
|
playSentence, S, TRACK_ORDER, trackLabel, store, tm, toast, usable, useTrack, when,
|
|
11
11
|
} from './core.js'
|
|
12
12
|
import { paneDrill, paneText, paneWords } from './listen.js'
|
|
@@ -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
|
// 从课程页回来:进度变了,后台重新拉一次课程列表
|
|
@@ -135,36 +137,35 @@ async function render(soft = false) {
|
|
|
135
137
|
function renderLibrary() {
|
|
136
138
|
const st = S.status || {}
|
|
137
139
|
const canImport = !S.status || st.ffmpeg
|
|
138
|
-
const warn = []
|
|
139
|
-
if (S.status && !st.models && canImport) warn.push('第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别。')
|
|
140
|
-
if (S.status && !st.llm && canImport) warn.push('没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。')
|
|
141
|
-
|
|
142
140
|
const groups = new Map()
|
|
143
141
|
for (const l of S.lessons) {
|
|
144
142
|
const g = l.group || ''
|
|
145
143
|
if (!groups.has(g)) groups.set(g, [])
|
|
146
144
|
groups.get(g).push(l)
|
|
147
145
|
}
|
|
146
|
+
const type = store.get('newType', '')
|
|
148
147
|
|
|
149
148
|
app.innerHTML = `
|
|
150
149
|
<div id="todayCard">${todayCard(S.today)}</div>
|
|
151
|
-
${
|
|
152
|
-
|
|
150
|
+
${canImport ? `
|
|
151
|
+
<div class="section-title">新建课程</div>
|
|
152
|
+
<div class="new-grid">
|
|
153
|
+
<button class="new-card ${type === 'audio' ? 'on' : ''}" data-new="audio">
|
|
154
|
+
<span class="new-ico">🎧</span><b>导入音频</b>
|
|
155
|
+
<span class="faint">有声书、讲座、引导音频……MP3 / M4A / WAV / FLAC。本机识别出每一句英文,AI 翻译讲解。</span>
|
|
156
|
+
</button>
|
|
157
|
+
<button class="new-card ${type === 'doc' ? 'on' : ''}" data-new="doc">
|
|
158
|
+
<span class="new-ico">📄</span><b>导入文档</b>
|
|
159
|
+
<span class="faint">文章、手册、书……整理好的英文文字(粘贴或 TXT / Markdown)。AI 朗读成音频,再翻译讲解。</span>
|
|
160
|
+
</button>
|
|
161
|
+
</div>
|
|
162
|
+
<div id="newPanel"></div>
|
|
163
|
+
<div id="jobs"></div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
|
|
164
|
+
<div class="section-title">我的课程</div>
|
|
165
|
+
${S.lessons.length ? '' : `<div class="empty">还没有课。上面选一种:导入英文音频,或导入英文文档。</div>`}
|
|
153
166
|
${[...groups].map(([g, ls]) => `
|
|
154
167
|
${g ? `<div class="group-title">${esc(g)}</div>` : (groups.size > 1 ? '<div class="group-title">其他</div>' : '')}
|
|
155
168
|
${ls.map(lessonCard).join('')}`).join('')}
|
|
156
|
-
${canImport ? `<div class="card">
|
|
157
|
-
<h3>导入英文音频</h3>
|
|
158
|
-
<div class="drop" id="drop">
|
|
159
|
-
<div class="row">
|
|
160
|
-
<input type="text" class="grow" id="path" placeholder="粘贴 MP3 文件或文件夹的路径" style="flex:1 1 200px">
|
|
161
|
-
<button class="btn primary" id="importBtn">导入</button>
|
|
162
|
-
</div>
|
|
163
|
-
<div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
|
|
164
|
-
<input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
|
|
165
|
-
</div>
|
|
166
|
-
<div id="jobs"></div>
|
|
167
|
-
</div>` : `<div class="card small muted">这里只用来学习:在电脑上导入、做好课,再用电脑上的「发送到 VPS」发过来。<div id="jobs"></div></div>`}
|
|
168
169
|
<div id="vpsCard"></div>
|
|
169
170
|
`
|
|
170
171
|
renderJobs()
|
|
@@ -172,7 +173,41 @@ function renderLibrary() {
|
|
|
172
173
|
renderVps()
|
|
173
174
|
app.querySelectorAll('.lesson').forEach(el => { el.onclick = () => go(`#/l/${el.dataset.id}`) })
|
|
174
175
|
if (!canImport) return
|
|
176
|
+
app.querySelectorAll('[data-new]').forEach(b => {
|
|
177
|
+
b.onclick = () => {
|
|
178
|
+
const t = store.get('newType', '') === b.dataset.new ? '' : b.dataset.new // 再点一次收起
|
|
179
|
+
store.set('newType', t)
|
|
180
|
+
app.querySelectorAll('[data-new]').forEach(x => x.classList.toggle('on', x.dataset.new === t))
|
|
181
|
+
renderNewPanel(t)
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
renderNewPanel(type)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 新建课程:展开所选的那种 */
|
|
188
|
+
function renderNewPanel(type) {
|
|
189
|
+
const box = $('#newPanel')
|
|
190
|
+
if (!box) return
|
|
191
|
+
if (type === 'audio') return newAudioPanel(box)
|
|
192
|
+
if (type === 'doc') return newDocPanel(box)
|
|
193
|
+
box.innerHTML = ''
|
|
194
|
+
}
|
|
175
195
|
|
|
196
|
+
function newAudioPanel(box) {
|
|
197
|
+
const st = S.status || {}
|
|
198
|
+
box.innerHTML = `<div class="card">
|
|
199
|
+
<h3>导入英文音频</h3>
|
|
200
|
+
${!st.models ? '<div class="faint">第一次导入会先下载语音识别模型(约 240 MB),之后都在本机识别,音频不上传。</div>' : ''}
|
|
201
|
+
${!st.llm ? '<div class="small" style="color:var(--mid)">没找到可用的大模型:只能识别英文,翻译讲解要等配置好模型后点「补全翻译」。</div>' : ''}
|
|
202
|
+
<div class="drop" id="drop" style="margin-top:8px">
|
|
203
|
+
<div class="row">
|
|
204
|
+
<input type="text" class="grow" id="path" placeholder="粘贴音频文件或文件夹的路径" style="flex:1 1 200px">
|
|
205
|
+
<button class="btn primary" id="importBtn">导入</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div class="faint" style="margin-top:8px">也可以把音频拖到这里,或 <a href="#" class="link" id="pick">选择文件</a>。文件夹会把里面的每个音频各做成一课。</div>
|
|
208
|
+
<input type="file" id="file" accept="audio/*,.mp3,.m4a,.m4b,.wav,.flac,.ogg,.opus,.aac" multiple hidden>
|
|
209
|
+
</div>
|
|
210
|
+
</div>`
|
|
176
211
|
const input = $('#path')
|
|
177
212
|
input.value = store.get('lastPath', '')
|
|
178
213
|
const doImport = async () => {
|
|
@@ -198,6 +233,104 @@ function renderLibrary() {
|
|
|
198
233
|
drop.ondrop = e => { e.preventDefault(); drop.classList.remove('over'); uploadFiles([...e.dataTransfer.files]) }
|
|
199
234
|
}
|
|
200
235
|
|
|
236
|
+
let docHelp = null
|
|
237
|
+
async function newDocPanel(box) {
|
|
238
|
+
box.innerHTML = '<div class="card"><div class="faint">…</div></div>'
|
|
239
|
+
try {
|
|
240
|
+
if (!docHelp) docHelp = await api('doc-help')
|
|
241
|
+
if (!S.voices) S.voices = await api('voices')
|
|
242
|
+
} catch (e) { box.innerHTML = `<div class="card small" style="color:var(--bad)">${esc(e.message)}</div>`; return }
|
|
243
|
+
const V = S.voices
|
|
244
|
+
const edgeReady = V.edge && V.edge.ready
|
|
245
|
+
const engine = store.get('docEngine', 'edge')
|
|
246
|
+
const list = engine === 'edge' ? ((V.edge && V.edge.en) || []) : ((V.kokoro && V.kokoro.voices) || []).map(v => ({ ...v, gender: v.id[1] === 'm' ? 'm' : 'f' }))
|
|
247
|
+
const voice = store.get(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', engine === 'edge' ? 'en-US-AndrewNeural' : 'am_michael')
|
|
248
|
+
const speed = store.get('docSpeed', 1)
|
|
249
|
+
const group = (g, name) => `<optgroup label="${name}">${list.filter(x => x.gender === g).map(x => `<option value="${esc(x.id)}" ${x.id === voice ? 'selected' : ''}>${esc(x.label)}</option>`).join('')}</optgroup>`
|
|
250
|
+
box.innerHTML = `<div class="card doc-new">
|
|
251
|
+
<h3>导入英文文档</h3>
|
|
252
|
+
<div class="step"><span class="num">1</span><div class="grow">
|
|
253
|
+
<b>先让 AI 把材料整理好</b>
|
|
254
|
+
<div class="faint">PDF、电子书、网页都行:把下面这段话连同材料一起发给 ChatGPT、DeepSeek、Kimi 等,它会整理成能直接导入的英文文字(只留正文、修好断行、按章节分好)。扫描版 PDF 也可以,让它先识别文字。</div>
|
|
255
|
+
${S.status && S.status.host === 'dsh' ? '<div class="faint" style="margin-top:4px">💡 更省事:在 DSH 的对话里把 PDF 发给 AI,说「按听懂的格式整理后导入听懂」,它会整理好直接导入,不用复制粘贴。</div>' : ''}
|
|
256
|
+
<details style="margin-top:6px"><summary class="link">查看整理要求</summary><pre class="prompt">${esc(docHelp.prompt)}</pre></details>
|
|
257
|
+
<button class="btn" id="copyPrompt" style="margin-top:6px">复制整理要求</button>
|
|
258
|
+
</div></div>
|
|
259
|
+
<div class="step"><span class="num">2</span><div class="grow">
|
|
260
|
+
<b>把整理好的文字粘贴进来</b>
|
|
261
|
+
<textarea id="docText" rows="8" placeholder="# 第一章标题 First paragraph of the chapter... Second paragraph... # 第二章标题 ..."></textarea>
|
|
262
|
+
<div class="row" style="margin-top:4px">
|
|
263
|
+
<span class="faint grow" id="docCount">每个「# 标题」开始一课;没有标题就整篇一课</span>
|
|
264
|
+
<a href="#" class="link" id="docPick">或选择 .txt / .md 文件</a>
|
|
265
|
+
<input type="file" id="docFile" accept=".txt,.md,.markdown,text/plain,text/markdown" hidden>
|
|
266
|
+
</div>
|
|
267
|
+
<input type="text" id="docTitle" placeholder="文档标题(可不填,用第一章的标题)" style="margin-top:6px">
|
|
268
|
+
</div></div>
|
|
269
|
+
<div class="step"><span class="num">3</span><div class="grow">
|
|
270
|
+
<b>选朗读的声音</b>
|
|
271
|
+
<div class="chips" style="margin-top:4px">
|
|
272
|
+
<button class="chip ${engine === 'edge' ? 'on' : ''}" data-docengine="edge">微软 Edge(最自然,要联网)</button>
|
|
273
|
+
<button class="chip ${engine === 'kokoro' ? 'on' : ''}" data-docengine="kokoro">Kokoro(离线)</button>
|
|
274
|
+
</div>
|
|
275
|
+
${engine === 'edge' && !edgeReady ? `<div class="faint">Edge 语音要先装一个小组件(edge-tts,约 15 MB,装在插件自己的目录里,需要电脑上有 Python 3)。文字会发到微软的服务器朗读。</div>
|
|
276
|
+
<button class="btn" id="docEdgeInstall" style="margin-top:6px">安装 Edge 语音组件</button>` : `
|
|
277
|
+
<div class="row" style="margin-top:4px"><select id="docVoice">${group('m', '男声')}${group('f', '女声')}</select></div>
|
|
278
|
+
<div class="chips" style="margin-top:6px">${[0.85, 1, 1.15].map(x => `<button class="chip ${x === speed ? 'on' : ''}" data-docspeed="${x}">${x === 1 ? '正常语速' : x < 1 ? '慢一点' : '快一点'}</button>`).join('')}</div>`}
|
|
279
|
+
</div></div>
|
|
280
|
+
<button class="btn primary big" id="docGo" ${engine === 'edge' && !edgeReady ? 'disabled' : ''}>开始导入</button>
|
|
281
|
+
</div>`
|
|
282
|
+
const ta = $('#docText')
|
|
283
|
+
ta.value = store.get('docDraft', '')
|
|
284
|
+
const count = () => {
|
|
285
|
+
const t = ta.value
|
|
286
|
+
const chapters = (t.match(/^#\s+\S/gm) || []).length
|
|
287
|
+
const words = (t.match(/[A-Za-z']+/g) || []).length
|
|
288
|
+
$('#docCount').textContent = t.trim() ? `${words.toLocaleString()} 个英文单词 · ${chapters ? `${chapters} 章(每章一课)` : '没有「# 标题」:整篇一课'}` : '每个「# 标题」开始一课;没有标题就整篇一课'
|
|
289
|
+
}
|
|
290
|
+
ta.oninput = () => { count(); try { store.set('docDraft', ta.value.slice(0, 200000)) } catch { /* 太大就不存草稿 */ } }
|
|
291
|
+
count()
|
|
292
|
+
$('#copyPrompt').onclick = () => copy(docHelp.prompt)
|
|
293
|
+
$('#docPick').onclick = e => { e.preventDefault(); $('#docFile').click() }
|
|
294
|
+
$('#docFile').onchange = async e => {
|
|
295
|
+
const f = e.target.files[0]
|
|
296
|
+
if (!f) return
|
|
297
|
+
ta.value = await f.text()
|
|
298
|
+
if (!$('#docTitle').value) $('#docTitle').value = f.name.replace(/\.(txt|md|markdown)$/i, '')
|
|
299
|
+
ta.oninput()
|
|
300
|
+
}
|
|
301
|
+
box.querySelectorAll('[data-docengine]').forEach(b => { b.onclick = () => { store.set('docEngine', b.dataset.docengine); newDocPanel(box) } })
|
|
302
|
+
box.querySelectorAll('[data-docspeed]').forEach(b => {
|
|
303
|
+
b.onclick = () => { store.set('docSpeed', Number(b.dataset.docspeed)); box.querySelectorAll('[data-docspeed]').forEach(x => x.classList.toggle('on', x === b)) }
|
|
304
|
+
})
|
|
305
|
+
const vs = $('#docVoice')
|
|
306
|
+
if (vs) vs.onchange = () => store.set(engine === 'edge' ? 'docVoiceEdge' : 'docVoiceKokoro', vs.value)
|
|
307
|
+
const inst = $('#docEdgeInstall')
|
|
308
|
+
if (inst) inst.onclick = async () => {
|
|
309
|
+
inst.disabled = true
|
|
310
|
+
inst.textContent = '正在安装…(第一次要一两分钟)'
|
|
311
|
+
try {
|
|
312
|
+
const r = await api('edge/install', {})
|
|
313
|
+
toast(r.ready ? `Edge 语音组件已装好(${r.version})` : '安装没成功')
|
|
314
|
+
S.voices = null
|
|
315
|
+
newDocPanel(box)
|
|
316
|
+
} catch (e) { toast(e.message); inst.disabled = false; inst.textContent = '安装 Edge 语音组件' }
|
|
317
|
+
}
|
|
318
|
+
$('#docGo').onclick = async () => {
|
|
319
|
+
const btn = $('#docGo')
|
|
320
|
+
btn.disabled = true
|
|
321
|
+
try {
|
|
322
|
+
const r = await api('import-doc', { text: ta.value, title: $('#docTitle').value.trim(), engine, voice: vs ? vs.value : undefined, speed: store.get('docSpeed', 1) })
|
|
323
|
+
const fresh = r.items.filter(x => !x.existed)
|
|
324
|
+
toast(fresh.length ? `《${r.title}》分成 ${r.items.length} 课,正在朗读和翻译` : '这份文档导入过了')
|
|
325
|
+
store.set('docDraft', '')
|
|
326
|
+
ta.value = ''
|
|
327
|
+
count()
|
|
328
|
+
pollJobsNow()
|
|
329
|
+
} catch (e) { toast(e.message) }
|
|
330
|
+
btn.disabled = false
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
201
334
|
// —— 今日学习卡片 ——
|
|
202
335
|
async function loadToday() {
|
|
203
336
|
try {
|
|
@@ -241,7 +374,7 @@ function lessonCard(l) {
|
|
|
241
374
|
error: '<span class="badge err">出错</span>',
|
|
242
375
|
}[l.status] || (s.due ? `<span class="badge">待复习 ${s.due}</span>` : (s.total && s.mastered === s.total ? '<span class="badge ok">已掌握</span>' : ''))
|
|
243
376
|
return `<div class="card lesson" data-id="${esc(l.id)}">
|
|
244
|
-
<div class="row"><div class="grow"><div class="t">${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
|
|
377
|
+
<div class="row"><div class="grow"><div class="t"><span class="kind ${l.kind === 'doc' ? 'doc' : ''}">${l.kind === 'doc' ? '文档' : '音频'}</span>${esc(l.titleZh || l.title)}</div>${l.titleZh ? `<div class="en">${esc(l.title)}</div>` : ''}</div>${badge}</div>
|
|
245
378
|
<div class="faint" style="margin:6px 0">${fmtDur(l.duration)} · ${l.sentences} 句${l.translated < l.sentences ? ` · 已翻译 ${l.translated}` : ''} · 学过 ${s.seen} · 掌握 ${s.mastered}/${s.total}${s.nextDue && !s.due ? ` · 下次复习 ${when(s.nextDue)}` : ''}</div>
|
|
246
379
|
<div class="bar two"><i class="seen" style="width:${seenPct}%"></i><i style="width:${pct}%"></i></div>
|
|
247
380
|
${l.error ? `<div class="small" style="color:var(--bad);margin-top:6px">${esc(l.error)}</div>` : ''}
|
|
@@ -359,7 +492,7 @@ async function copy(text) {
|
|
|
359
492
|
|
|
360
493
|
// ———————————————————— 一节课 ————————————————————
|
|
361
494
|
|
|
362
|
-
const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇']]
|
|
495
|
+
const TABS = [['overview', '概览'], ['study', '精听'], ['review', '复习'], ['text', '连续听'], ['drill', '磨耳朵'], ['words', '词汇'], ['export', '导出']]
|
|
363
496
|
|
|
364
497
|
function renderLesson(tab) {
|
|
365
498
|
const l = S.cur.lesson
|
|
@@ -381,7 +514,7 @@ function renderLesson(tab) {
|
|
|
381
514
|
bindManage(pane, l)
|
|
382
515
|
return
|
|
383
516
|
}
|
|
384
|
-
;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords }[tab] || paneOverview)(pane, l, st)
|
|
517
|
+
;({ overview: paneOverview, study: paneStudy, review: paneReview, text: paneText, drill: paneDrill, words: paneWords, export: paneExport }[tab] || paneOverview)(pane, l, st)
|
|
385
518
|
}
|
|
386
519
|
|
|
387
520
|
/** 课程页上方的「声音」选择条 */
|
|
@@ -438,7 +571,7 @@ function paneOverview(pane, l, st) {
|
|
|
438
571
|
pane.innerHTML = `
|
|
439
572
|
<div class="card">
|
|
440
573
|
${l.titleZh ? `<div style="font-weight:600">${esc(l.titleZh)}</div><div class="faint">${esc(l.title)}</div>` : `<div style="font-weight:600">${esc(l.title)}</div>`}
|
|
441
|
-
<div class="faint" style="margin-top:2px">${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
|
|
574
|
+
<div class="faint" style="margin-top:2px">${l.kind === 'doc' ? `文档 · 朗读:${esc(l.voiceLabel || 'AI')} · ` : ''}${fmtDur(l.duration)} · ${l.sentences.length} 句${l.model ? ` · 翻译:${esc(l.model)}` : ''}</div>
|
|
442
575
|
${l.summaryZh ? `<p class="summary">${esc(l.summaryZh)}</p>` : ''}
|
|
443
576
|
${l.note ? `<div class="small" style="color:var(--mid)">${esc(l.note)}</div>` : ''}
|
|
444
577
|
${l.status === 'error' ? `<div class="small" style="color:var(--bad)">${esc(l.error)}</div>` : ''}
|
|
@@ -460,7 +593,10 @@ function paneOverview(pane, l, st) {
|
|
|
460
593
|
${stars ? `<div class="faint" style="margin-top:6px">难句本:收藏了 ${stars} 句,在「复习」或「磨耳朵」里选「收藏」专门练。</div>` : ''}
|
|
461
594
|
</div>
|
|
462
595
|
${voicesCard(l)}
|
|
463
|
-
|
|
596
|
+
<div class="card row">
|
|
597
|
+
<div class="grow"><b>导出</b><div class="faint">中文配音(MP3)、双语字幕、LRC 歌词、录音稿</div></div>
|
|
598
|
+
<button class="btn" id="goExport">去导出 ›</button>
|
|
599
|
+
</div>
|
|
464
600
|
<div class="card">
|
|
465
601
|
<h3>怎么练到「一听就懂」</h3>
|
|
466
602
|
<ol class="steps">
|
|
@@ -474,7 +610,7 @@ function paneOverview(pane, l, st) {
|
|
|
474
610
|
</div>
|
|
475
611
|
<div class="card"><h3>管理</h3>${manageButtons(l)}</div>`
|
|
476
612
|
bindVoices(pane, l)
|
|
477
|
-
|
|
613
|
+
$('#goExport').onclick = () => go(`#/l/${l.id}/export`)
|
|
478
614
|
$('#goStudy').onclick = () => go(`#/l/${l.id}/study`)
|
|
479
615
|
$('#goReview').onclick = () => go(`#/l/${l.id}/review`)
|
|
480
616
|
$('#goDrill').onclick = () => go(`#/l/${l.id}/drill`)
|
|
@@ -499,6 +635,8 @@ function voicesCard(l) {
|
|
|
499
635
|
const opts = (list, sel) => list.map(x => `<option value="${esc(x.id)}" ${x.id === sel ? 'selected' : ''}>${esc(x.label)}</option>`).join('')
|
|
500
636
|
const speeds = sel => [0.8, 0.9, 1].map(x => `<option value="${x}" ${x === sel ? 'selected' : ''}>${x === 1 ? '正常语速' : x + ' 倍语速'}</option>`).join('')
|
|
501
637
|
const row = name => {
|
|
638
|
+
// 文档课没有真实录音:「去空白」「压背景」没意义
|
|
639
|
+
if (isDoc() && (name === 'clean' || name === 'cleannr')) return ''
|
|
502
640
|
const meta = name === 'full' ? { duration: l.duration } : S.cur.tracks[name]
|
|
503
641
|
if (!meta && !canMake) return ''
|
|
504
642
|
const job = busy(name)
|
|
@@ -513,7 +651,7 @@ function voicesCard(l) {
|
|
|
513
651
|
return `<div class="track ${name === t ? 'on' : ''}">
|
|
514
652
|
<div class="row"><b class="grow">${esc(trackLabel(name))}</b>
|
|
515
653
|
${meta ? `<span class="faint">${fmt(meta.duration)}${meta.voice ? ' · ' + esc(meta.voice) : ''}</span>` : '<span class="faint">未生成</span>'}</div>
|
|
516
|
-
<div class="faint">${TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
|
|
654
|
+
<div class="faint">${name === 'full' && isDoc() ? '导入文档时 AI 朗读生成的音频' : TRACK_NOTES[name]}${name === 'kokoro' && v.kokoro && !v.kokoro.ready && canMake ? ';第一次生成会下载约 103 MB 语音模型' : ''}</div>
|
|
517
655
|
${stale ? '<div class="small" style="color:var(--mid)">句子改过了,这条声音对不上了,建议重新生成</div>' : ''}
|
|
518
656
|
${job ? `<div class="small muted" style="margin-top:4px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : `
|
|
519
657
|
<div class="row" style="margin-top:6px">
|
|
@@ -564,6 +702,12 @@ let zhAudio = null // 试听用的独立播放器,不打扰课程的播放器
|
|
|
564
702
|
function stopZhAudio() { if (zhAudio) { zhAudio.pause(); zhAudio = null } }
|
|
565
703
|
window.addEventListener('hashchange', stopZhAudio)
|
|
566
704
|
|
|
705
|
+
/** 「导出」页签 */
|
|
706
|
+
function paneExport(pane, l) {
|
|
707
|
+
pane.innerHTML = zhCard(l)
|
|
708
|
+
bindZh(pane, l)
|
|
709
|
+
}
|
|
710
|
+
|
|
567
711
|
const TEXT_FORMATS = [['srt-bi', '双语字幕'], ['srt-en', '英文字幕'], ['srt-zh', '中文字幕'], ['lrc', '歌词 LRC'], ['script', '录音稿'], ['csv', '表格']]
|
|
568
712
|
|
|
569
713
|
function zhCard(l) {
|
|
@@ -590,6 +734,7 @@ function zhCard(l) {
|
|
|
590
734
|
<div class="row" style="margin-top:6px">
|
|
591
735
|
<button class="btn" id="zhPlay">▶ 试听</button>
|
|
592
736
|
<a class="btn primary" href="audio?id=${encodeURIComponent(l.id)}&track=zh&download=1" download>下载 MP3</a>
|
|
737
|
+
${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
738
|
</div>` : ''}
|
|
594
739
|
${job ? `<div class="small muted" style="margin-top:8px">${job.state === 'queued' ? '排队中' : esc(job.stage)}${job.total ? ` ${job.done}/${job.total}` : ''}…</div>` : canMake ? `
|
|
595
740
|
<div class="form">
|
|
@@ -687,6 +832,22 @@ function bindZh(root, l) {
|
|
|
687
832
|
}
|
|
688
833
|
}
|
|
689
834
|
|
|
835
|
+
// ———————————————————— 版本号(首页标题旁) ————————————————————
|
|
836
|
+
// 更新、卸载在 DSH 的「设置 → 听懂」里;这里只显示版本,有新版本时提醒一句。
|
|
837
|
+
let manageInfo = null
|
|
838
|
+
async function renderManage() {
|
|
839
|
+
const box = $('#manage')
|
|
840
|
+
if (!manageInfo) {
|
|
841
|
+
try { manageInfo = await api('update') } catch { manageInfo = null }
|
|
842
|
+
}
|
|
843
|
+
const m = manageInfo
|
|
844
|
+
if (!m || !m.managed) { box.hidden = true; return }
|
|
845
|
+
box.hidden = route().view !== 'library'
|
|
846
|
+
box.innerHTML = `<span class="faint ver">v${esc(m.running)}</span>${m.restartPending
|
|
847
|
+
? `<span class="badge warn">v${esc(m.restartPending)} 已装好,重启 DSH 生效</span>`
|
|
848
|
+
: m.available ? `<span class="badge" title="在 DSH 的「设置 → 听懂」里点更新">有新版本 v${esc(m.latest)} · 设置 → 听懂 里更新</span>` : ''}`
|
|
849
|
+
}
|
|
850
|
+
|
|
690
851
|
// ———————————————————— 启动 ————————————————————
|
|
691
852
|
|
|
692
853
|
;(async () => {
|