tronclass-auto 1.7.0 → 1.7.2

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +17 -6
  3. package/src/tui.js +275 -409
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tronclass-auto",
3
- "version": "1.7.0",
3
+ "version": "1.7.2",
4
4
  "description": "自動觀看 eclass/TronClass 影片、填寫表單的 CLI 工具",
5
5
  "main": "src/index.js",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -273,16 +273,17 @@ async function showStatus() {
273
273
  const browser = await chromium.launch(launchOpts);
274
274
  const context = await browser.newContext();
275
275
  await context.addCookies(cookies);
276
- const page = await context.newPage();
277
276
 
278
- await page.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
279
- await page.waitForTimeout(2000);
277
+ const checkPage = await context.newPage();
278
+ await checkPage.goto(BASE_URL, { waitUntil: 'domcontentloaded', timeout: 15000 });
279
+ await checkPage.waitForTimeout(2000);
280
280
 
281
- if (page.url().includes('login')) {
281
+ if (checkPage.url().includes('login')) {
282
282
  console.log(chalk.red('[ERROR] Cookie 過期'));
283
283
  await browser.close();
284
284
  return;
285
285
  }
286
+ await checkPage.close();
286
287
 
287
288
  const courses = config.courses || [];
288
289
  console.log(chalk.cyan('\n┌' + '─'.repeat(48) + '┐'));
@@ -293,11 +294,21 @@ async function showStatus() {
293
294
  let totalRemaining = 0;
294
295
  let totalExams = 0;
295
296
 
296
- for (const courseUrl of courses) {
297
+ const courseResults = await Promise.all(courses.map(async (courseUrl) => {
297
298
  const match = courseUrl.match(/\/course\/(\d+)\//);
298
299
  const courseId = match ? match[1] : '?';
299
300
  try {
300
- const allVideos = await getAllVideos(page, courseId);
301
+ const pg = await context.newPage();
302
+ const allVideos = await getAllVideos(pg, courseId);
303
+ await pg.close();
304
+ return { courseId, allVideos };
305
+ } catch (e) {
306
+ return { courseId, allVideos: [] };
307
+ }
308
+ }));
309
+
310
+ for (const { courseId, allVideos } of courseResults) {
311
+ try {
301
312
  const videos = allVideos.filter(u => u.type === 'online_video');
302
313
  const completed = videos.filter(v => v.completeness === 'full').length;
303
314
  const future = videos.filter(v => v.isFuture && v.completeness !== 'full').length;
package/src/tui.js CHANGED
@@ -1,59 +1,141 @@
1
- const inquirer = require('inquirer');
1
+ const readline = require('readline');
2
2
  const chalk = require('chalk');
3
3
  const { loadConfig, saveConfig, BASE_URL } = require('./config');
4
4
  const { loadCookies } = require('./auth');
5
5
  const pkg = require('../package.json');
6
6
 
7
- async function getCourseName(page, courseId) {
8
- try {
9
- await page.goto(`${BASE_URL}/course/${courseId}/content#/`, {
10
- waitUntil: 'domcontentloaded',
11
- timeout: 15000
12
- });
13
- await page.waitForTimeout(3000);
14
- return await page.evaluate(() => {
15
- const el = document.querySelector('.learning-activities');
16
- if (!el) return null;
17
- const scope = angular.element(el).scope();
18
- if (!scope) return null;
19
- return scope.course ? scope.course.title || scope.course.name : null;
20
- });
21
- } catch (e) {
22
- return null;
23
- }
7
+ function box(lines, width = 52) {
8
+ console.log(chalk.cyan('╔' + '═'.repeat(width - 2) + '╗'));
9
+ lines.forEach(l => console.log(chalk.cyan('║ ') + l.padEnd(width - 4) + chalk.cyan(' ║')));
10
+ console.log(chalk.cyan('╚' + '═'.repeat(width - 2) + '╝'));
24
11
  }
25
12
 
26
- async function withEsc(promptFn, onEsc) {
27
- let resolved = false;
28
- process.stdin.setRawMode(true);
29
- process.stdin.resume();
30
- const escHandler = (data) => {
31
- if (data.toString() === '\u001b' && !resolved) {
32
- resolved = true;
33
- process.stdin.removeListener('data', escHandler);
13
+ function promptList(message, choices, header) {
14
+ return new Promise((resolve) => {
15
+ let selected = 0;
16
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
17
+
18
+ function render() {
19
+ const lines = [];
20
+ lines.push('');
21
+ choices.forEach((c, i) => {
22
+ const prefix = i === selected ? chalk.green('>') : ' ';
23
+ if (typeof c === 'string' && c === '__sep__') {
24
+ lines.push(chalk.gray(' ─────────────'));
25
+ } else {
26
+ const label = typeof c === 'string' ? c : c.name;
27
+ lines.push(` ${prefix} ${label}`);
28
+ }
29
+ });
30
+ lines.push('');
31
+ lines.push(chalk.gray(' ↑↓ 移動 Enter 確認 ESC 返回'));
32
+
33
+ const headerLines = header || [];
34
+ const totalLines = headerLines.length + lines.length;
35
+
36
+ process.stdout.write('\x1B[2J\x1B[H');
37
+ headerLines.forEach(l => process.stdout.write(l + '\n'));
38
+ lines.forEach(l => process.stdout.write(l + '\n'));
39
+ }
40
+
41
+ render();
42
+
43
+ process.stdin.setRawMode(true);
44
+ process.stdin.resume();
45
+
46
+ function onData(data) {
47
+ const key = data.toString();
48
+
49
+ if (key === '\u001b[A') {
50
+ selected = Math.max(0, selected - 1);
51
+ while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
52
+ selected = Math.max(0, selected - 1);
53
+ }
54
+ render();
55
+ } else if (key === '\u001b[B') {
56
+ selected = Math.min(choices.length - 1, selected + 1);
57
+ while (selected < choices.length && (typeof choices[selected] === 'string' || choices[selected].value === '__sep__')) {
58
+ selected = Math.min(choices.length - 1, selected + 1);
59
+ }
60
+ render();
61
+ } else if (key === '\r' || key === '\n') {
62
+ cleanup();
63
+ const choice = choices[selected];
64
+ resolve(typeof choice === 'string' ? choice : choice.value);
65
+ } else if (key === '\u001b') {
66
+ cleanup();
67
+ resolve('__esc__');
68
+ }
69
+ }
70
+
71
+ function cleanup() {
72
+ process.stdin.removeListener('data', onData);
34
73
  process.stdin.setRawMode(false);
35
74
  process.stdin.pause();
36
- onEsc();
75
+ rl.close();
37
76
  }
38
- };
39
- process.stdin.on('data', escHandler);
40
- try {
41
- await promptFn();
42
- } finally {
43
- if (!resolved) {
44
- process.stdin.removeListener('data', escHandler);
77
+
78
+ process.stdin.on('data', onData);
79
+ });
80
+ }
81
+
82
+ function promptConfirm(message, defaultVal = true) {
83
+ return new Promise((resolve) => {
84
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
85
+ process.stdout.write(` ${message} (${defaultVal ? 'Y/n' : 'y/N'}) `);
86
+
87
+ process.stdin.setRawMode(true);
88
+ process.stdin.resume();
89
+
90
+ function onData(data) {
91
+ const key = data.toString();
92
+ if (key === '\r' || key === '\n') {
93
+ cleanup();
94
+ resolve(defaultVal);
95
+ } else if (key === '\u001b') {
96
+ cleanup();
97
+ resolve(null);
98
+ } else if (key.toLowerCase() === 'y') {
99
+ cleanup();
100
+ resolve(true);
101
+ } else if (key.toLowerCase() === 'n') {
102
+ cleanup();
103
+ resolve(false);
104
+ }
45
105
  }
46
- resolved = true;
47
- }
106
+
107
+ function cleanup() {
108
+ process.stdin.removeListener('data', onData);
109
+ process.stdin.setRawMode(false);
110
+ process.stdin.pause();
111
+ rl.close();
112
+ }
113
+
114
+ process.stdin.on('data', onData);
115
+ });
48
116
  }
49
117
 
50
- function box(lines, width = 52) {
51
- const top = '╔' + '═'.repeat(width - 2) + '╗';
52
- const bot = '╚' + '═'.repeat(width - 2) + '╝';
53
- const pad = (s) => '║ ' + s.padEnd(width - 4) + ' ║';
54
- console.log(chalk.cyan(top));
55
- lines.forEach(l => console.log(chalk.cyan(pad(l))));
56
- console.log(chalk.cyan(bot));
118
+ function promptInput(message) {
119
+ return new Promise((resolve) => {
120
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
121
+ rl.question(` ${message}: `, (answer) => {
122
+ rl.close();
123
+ resolve(answer);
124
+ });
125
+ });
126
+ }
127
+
128
+ function pressAnyKey() {
129
+ return new Promise((resolve) => {
130
+ process.stdout.write(chalk.gray('\n 按任意鍵返回...'));
131
+ process.stdin.setRawMode(true);
132
+ process.stdin.resume();
133
+ process.stdin.once('data', () => {
134
+ process.stdin.setRawMode(false);
135
+ process.stdin.pause();
136
+ resolve();
137
+ });
138
+ });
57
139
  }
58
140
 
59
141
  async function mainMenu() {
@@ -65,47 +147,33 @@ async function mainMenu() {
65
147
 
66
148
  let running = true;
67
149
  while (running) {
68
- console.clear();
69
150
  const config = loadConfig();
70
151
 
152
+ console.clear();
71
153
  box([
72
154
  chalk.bold.white(`tronclass-auto v${pkg.version}`),
73
155
  chalk.gray('自動觀看 eclass/TronClass 影片'),
74
156
  '',
75
- `課程: ${chalk.cyan(config.courses.length + ' 個')} | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
157
+ `課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`,
76
158
  ]);
77
- console.log('');
78
-
79
- const { action } = await new Promise((resolve) => {
80
- process.stdin.setRawMode(true);
81
- process.stdin.resume();
82
- const escHandler = (data) => {
83
- if (data.toString() === '\u001b') {
84
- process.stdin.removeListener('data', escHandler);
85
- process.stdin.setRawMode(false);
86
- process.stdin.pause();
87
- resolve({ action: 'exit' });
88
- }
89
- };
90
- process.stdin.on('data', escHandler);
91
- inquirer.prompt([{
92
- type: 'list',
93
- name: 'action',
94
- message: '選擇操作:',
95
- choices: [
96
- { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
97
- { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
98
- { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
99
- { name: chalk.magenta('⚙ 設定'), value: 'settings' },
100
- new inquirer.Separator(),
101
- { name: chalk.gray('🚪 離開'), value: 'exit' }
102
- ],
103
- pageSize: 10
104
- }]).then(ans => {
105
- process.stdin.removeListener('data', escHandler);
106
- resolve(ans);
107
- });
108
- });
159
+
160
+ const action = await promptList('選擇操作:', [
161
+ { name: chalk.green('▶ 開始自動觀看'), value: 'run' },
162
+ { name: chalk.cyan('📋 管理課程列表'), value: 'courses' },
163
+ { name: chalk.yellow('📊 查看進度統計'), value: 'status' },
164
+ { name: chalk.magenta('⚙ 設定'), value: 'settings' },
165
+ '__sep__',
166
+ { name: chalk.gray('🚪 離開'), value: 'exit' }
167
+ ], [
168
+ '',
169
+ chalk.white('╔' + '═'.repeat(50) + '╗'),
170
+ chalk.white('║') + chalk.bold.white(` tronclass-auto v${pkg.version}`.padEnd(50)) + chalk.white('║'),
171
+ chalk.white('║') + chalk.gray(' 自動觀看 eclass/TronClass 影片'.padEnd(50)) + chalk.white('║'),
172
+ chalk.white('║') + ` 課程: ${chalk.cyan(config.courses.length)} 個 | Cookie: ${chalk.green('✓')} | 倍速: ${chalk.yellow(config.playbackRate || 2)}x`.padEnd(50) + chalk.white('║'),
173
+ chalk.white('╚' + '═'.repeat(50) + '╝'),
174
+ ]);
175
+
176
+ if (action === '__esc__') { running = false; continue; }
109
177
 
110
178
  switch (action) {
111
179
  case 'run': await runMenu(config); break;
@@ -130,52 +198,11 @@ async function runMenu(config) {
130
198
  return;
131
199
  }
132
200
 
133
- const { confirm } = await new Promise((resolve) => {
134
- process.stdin.setRawMode(true);
135
- process.stdin.resume();
136
- const escHandler = (data) => {
137
- if (data.toString() === '\u001b') {
138
- process.stdin.removeListener('data', escHandler);
139
- process.stdin.setRawMode(false);
140
- process.stdin.pause();
141
- resolve({ confirm: false });
142
- }
143
- };
144
- process.stdin.on('data', escHandler);
145
- inquirer.prompt([{
146
- type: 'confirm',
147
- name: 'confirm',
148
- message: `開始觀看全部 ${config.courses.length} 個課程?`,
149
- default: true
150
- }]).then(ans => {
151
- process.stdin.removeListener('data', escHandler);
152
- resolve(ans);
153
- });
154
- });
155
- if (!confirm) return;
201
+ const confirm = await promptConfirm(`開始觀看全部 ${config.courses.length} 個課程?`);
202
+ if (confirm === null) return;
156
203
 
157
- const { headless } = await new Promise((resolve) => {
158
- process.stdin.setRawMode(true);
159
- process.stdin.resume();
160
- const escHandler = (data) => {
161
- if (data.toString() === '\u001b') {
162
- process.stdin.removeListener('data', escHandler);
163
- process.stdin.setRawMode(false);
164
- process.stdin.pause();
165
- resolve({ headless: false });
166
- }
167
- };
168
- process.stdin.on('data', escHandler);
169
- inquirer.prompt([{
170
- type: 'confirm',
171
- name: 'headless',
172
- message: '無頭模式(背景執行)?',
173
- default: false
174
- }]).then(ans => {
175
- process.stdin.removeListener('data', escHandler);
176
- resolve(ans);
177
- });
178
- });
204
+ const headless = await promptConfirm('無頭模式(背景執行)?', false);
205
+ if (headless === null) return;
179
206
 
180
207
  console.log('');
181
208
  box([
@@ -193,181 +220,104 @@ async function runMenu(config) {
193
220
 
194
221
  async function courseMenu() {
195
222
  let editing = true;
196
- const { chromium } = require('playwright');
197
- const { ensureBrowser } = require('./browser');
198
- let browser = null;
199
-
200
- async function getPage() {
201
- if (!browser) {
202
- const { loadCookies } = require('./auth');
203
- const cookies = loadCookies();
204
- const exePath = await ensureBrowser();
205
- const launchOpts = { headless: true, slowMo: 50, args: ['--disable-gpu', '--disable-software-rasterizer'] };
206
- if (exePath) launchOpts.executablePath = exePath;
207
- browser = await chromium.launch(launchOpts);
208
- const context = await browser.newContext();
209
- await context.addCookies(cookies);
210
- return await context.newPage();
211
- }
212
- return browser.newPage();
213
- }
223
+ while (editing) {
224
+ const config = loadConfig();
225
+ const courses = config.courses || [];
226
+
227
+ console.clear();
214
228
 
215
- let courseNames = {};
229
+ let courseNames = {};
230
+ if (courses.length > 0) {
231
+ try {
232
+ const { chromium } = require('playwright');
233
+ const cookies = loadCookies();
234
+ if (cookies.length > 0) {
235
+ const browser = await chromium.launch({ headless: true, slowMo: 50 });
236
+ const ctx = await browser.newContext();
237
+ await ctx.addCookies(cookies);
216
238
 
217
- async function loadCourseNames(courses) {
218
- console.log(chalk.gray(' 載入課程名稱...'));
219
- const page = await getPage();
220
- for (const url of courses) {
239
+ const results = await Promise.all(courses.map(async (url) => {
240
+ const match = url.match(/\/course\/(\d+)\//);
241
+ if (!match) return { id: '?', name: '' };
242
+ const pg = await ctx.newPage();
243
+ try {
244
+ await pg.goto(`${BASE_URL}/course/${match[1]}/content#/`, { waitUntil: 'domcontentloaded', timeout: 8000 });
245
+ await pg.waitForTimeout(2000);
246
+ const name = await pg.evaluate(() => {
247
+ const el = document.querySelector('.learning-activities');
248
+ if (!el) return '';
249
+ const scope = angular.element(el).scope();
250
+ return (scope && scope.course) ? (scope.course.name || '') : '';
251
+ });
252
+ return { id: match[1], name: name.substring(0, 30) };
253
+ } catch (e) {
254
+ return { id: match[1], name: '' };
255
+ } finally {
256
+ await pg.close();
257
+ }
258
+ }));
259
+
260
+ results.forEach(r => { if (r.name) courseNames[r.id] = r.name; });
261
+ await browser.close();
262
+ }
263
+ } catch (e) {}
264
+ }
265
+
266
+ const choices = [
267
+ { name: chalk.green('➕ 新增課程'), value: 'add' },
268
+ '__sep__',
269
+ ];
270
+ courses.forEach((url, i) => {
221
271
  const match = url.match(/\/course\/(\d+)\//);
222
- const id = match ? match[1] : null;
223
- if (id && !courseNames[id]) {
224
- const name = await getCourseName(page, id);
225
- if (name) courseNames[id] = name;
226
- }
272
+ const id = match ? match[1] : '?';
273
+ const name = courseNames[id] || id;
274
+ choices.push({ name: `${chalk.red('✕')} 移除 ${name} [${id}]`, value: `remove_${i}` });
275
+ });
276
+ if (courses.length > 0) {
277
+ choices.push({ name: chalk.red('🗑 清空所有'), value: 'clear' });
227
278
  }
228
- try { await page.close(); } catch (e) {}
229
- }
279
+ choices.push('__sep__');
280
+ choices.push({ name: chalk.gray('⬅ 返回'), value: 'back' });
230
281
 
231
- try {
232
- while (editing) {
233
- const config = loadConfig();
234
- const courses = config.courses || [];
235
-
236
- await loadCourseNames(courses);
237
-
238
- console.clear();
239
- box([
240
- chalk.bold.white('📋 課程列表'),
241
- ...(courses.length === 0
242
- ? [chalk.gray(' 還沒有課程')]
243
- : courses.map((url, i) => {
244
- const match = url.match(/\/course\/(\d+)\//);
245
- const id = match ? match[1] : '?';
246
- const name = courseNames[id] || null;
247
- if (name) {
248
- return ` ${chalk.white(i + 1 + '.')} ${chalk.cyan(name)} ${chalk.gray(`[${id}]`)}`;
249
- }
250
- return ` ${chalk.white(i + 1 + '.')} [${chalk.cyan(id)}] ${chalk.gray(url.substring(0, 45))}`;
251
- })),
252
- '',
253
- chalk.gray(' 新增: tronclass course --add <ID或URL>'),
254
- chalk.gray(' 移除: tronclass course --remove <編號>'),
255
- chalk.gray(' ESC 返回'),
256
- ]);
257
- console.log('');
258
-
259
- const { action } = await new Promise((resolve) => {
260
- const listChoices = [
261
- { name: chalk.green('➕ 新增課程(互動式)'), value: 'add' },
262
- ...(courses.length > 0 ? [
263
- ...courses.map((url, i) => {
264
- const match = url.match(/\/course\/(\d+)\//);
265
- const id = match ? match[1] : '?';
266
- const name = courseNames[id] || null;
267
- const label = name ? `${name} [${id}]` : `[${id}]`;
268
- return { name: `${chalk.red('✕')} 移除 ${label}`, value: `remove_${i}` };
269
- }),
270
- new inquirer.Separator(),
271
- { name: chalk.red('🗑 清空所有'), value: 'clear' }
272
- ] : []),
273
- new inquirer.Separator(),
274
- { name: chalk.gray('⬅ 返回'), value: 'back' }
275
- ];
276
-
277
- process.stdin.setRawMode(true);
278
- process.stdin.resume();
279
- const escHandler = (data) => {
280
- if (data.toString() === '\u001b') {
281
- process.stdin.removeListener('data', escHandler);
282
- process.stdin.setRawMode(false);
283
- process.stdin.pause();
284
- resolve({ action: 'back' });
285
- }
286
- };
287
- process.stdin.on('data', escHandler);
288
-
289
- inquirer.prompt([{
290
- type: 'list',
291
- name: 'action',
292
- message: `課程管理 (${courses.length} 個)`,
293
- choices: listChoices,
294
- pageSize: 20
295
- }]).then(ans => {
296
- process.stdin.removeListener('data', escHandler);
297
- resolve(ans);
298
- });
299
- });
282
+ const action = await promptList(`課程管理 (${courses.length} 個):`, choices, [
283
+ '',
284
+ chalk.white('┌' + '─'.repeat(50) + '┐'),
285
+ chalk.white('│') + chalk.bold.white(' 📋 課程列表'.padEnd(50)) + chalk.white('│'),
286
+ chalk.white('├' + '─'.repeat(50) + '┤'),
287
+ ...courses.map((url, i) => {
288
+ const match = url.match(/\/course\/(\d+)\//);
289
+ const id = match ? match[1] : '?';
290
+ const name = courseNames[id] || id;
291
+ return chalk.white('│') + ` ${chalk.white(i + 1 + '.')} ${chalk.cyan(name)} [${id}]`.padEnd(51) + chalk.white('│');
292
+ }),
293
+ chalk.white('└' + '─'.repeat(50) + '┘'),
294
+ ]);
300
295
 
301
- if (action === 'back') {
302
- editing = false;
303
- } else if (action === 'add') {
304
- const { input } = await new Promise((resolve) => {
305
- process.stdin.setRawMode(true);
306
- process.stdin.resume();
307
- const escHandler = (data) => {
308
- if (data.toString() === '\u001b') {
309
- process.stdin.removeListener('data', escHandler);
310
- process.stdin.setRawMode(false);
311
- process.stdin.pause();
312
- resolve({ input: '' });
313
- }
314
- };
315
- process.stdin.on('data', escHandler);
316
- inquirer.prompt([{
317
- type: 'input',
318
- name: 'input',
319
- message: '輸入課程 ID(如 127331)或完整 URL:',
320
- validate: (v) => v.trim().length > 0 ? true : '不可為空'
321
- }]).then(ans => {
322
- process.stdin.removeListener('data', escHandler);
323
- resolve(ans);
324
- });
325
- });
326
- if (input) {
327
- const config2 = loadConfig();
328
- config2.courses = config2.courses || [];
329
- config2.courses.push(normalizeUrl(input.trim()));
330
- saveConfig(config2);
331
- console.log(chalk.green(' 已新增!'));
332
- }
333
- } else if (action === 'clear') {
334
- const { confirm } = await new Promise((resolve) => {
335
- process.stdin.setRawMode(true);
336
- process.stdin.resume();
337
- const escHandler = (data) => {
338
- if (data.toString() === '\u001b') {
339
- process.stdin.removeListener('data', escHandler);
340
- process.stdin.setRawMode(false);
341
- process.stdin.pause();
342
- resolve({ confirm: false });
343
- }
344
- };
345
- process.stdin.on('data', escHandler);
346
- inquirer.prompt([{
347
- type: 'confirm', name: 'confirm', message: '確定要清空所有課程?', default: false
348
- }]).then(ans => {
349
- process.stdin.removeListener('data', escHandler);
350
- resolve(ans);
351
- });
352
- });
353
- if (confirm) {
354
- const config2 = loadConfig();
355
- config2.courses = [];
356
- saveConfig(config2);
357
- console.log(chalk.green(' 已清空'));
358
- }
359
- } else if (action.startsWith('remove_')) {
360
- const idx = parseInt(action.split('_')[1]);
361
- const config2 = loadConfig();
362
- const removed = config2.courses.splice(idx, 1)[0];
363
- saveConfig(config2);
364
- courseNames = {};
365
- console.log(chalk.green(` 已移除: ${removed}`));
296
+ if (action === '__esc__' || action === 'back') {
297
+ editing = false;
298
+ } else if (action === 'add') {
299
+ const input = await promptInput('輸入課程 ID 或 URL');
300
+ if (input && input.trim()) {
301
+ config.courses = config.courses || [];
302
+ config.courses.push(normalizeUrl(input.trim()));
303
+ saveConfig(config);
304
+ console.log(chalk.green(' 已新增!'));
305
+ await pressAnyKey();
366
306
  }
367
- }
368
- } finally {
369
- if (browser) {
370
- try { await browser.close(); } catch (e) {}
307
+ } else if (action === 'clear') {
308
+ const confirm = await promptConfirm('確定要清空所有課程?', false);
309
+ if (confirm) {
310
+ config.courses = [];
311
+ saveConfig(config);
312
+ console.log(chalk.green(' 已清空'));
313
+ await pressAnyKey();
314
+ }
315
+ } else if (typeof action === 'string' && action.startsWith('remove_')) {
316
+ const idx = parseInt(action.split('_')[1]);
317
+ const removed = config.courses.splice(idx, 1)[0];
318
+ saveConfig(config);
319
+ console.log(chalk.green(` 已移除: ${removed}`));
320
+ await pressAnyKey();
371
321
  }
372
322
  }
373
323
  }
@@ -375,112 +325,46 @@ async function courseMenu() {
375
325
  async function settingsMenu() {
376
326
  const config = loadConfig();
377
327
 
378
- const { setting } = await new Promise((resolve) => {
379
- process.stdin.setRawMode(true);
380
- process.stdin.resume();
381
- const escHandler = (data) => {
382
- if (data.toString() === '\u001b') {
383
- process.stdin.removeListener('data', escHandler);
384
- process.stdin.setRawMode(false);
385
- process.stdin.pause();
386
- resolve({ setting: 'back' });
387
- }
388
- };
389
- process.stdin.on('data', escHandler);
390
- inquirer.prompt([{
391
- type: 'list',
392
- name: 'setting',
393
- message: '設定',
394
- choices: [
395
- { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
396
- { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
397
- { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
398
- new inquirer.Separator(),
399
- { name: chalk.gray('⬅ 返回'), value: 'back' }
400
- ]
401
- }]).then(ans => {
402
- process.stdin.removeListener('data', escHandler);
403
- resolve(ans);
404
- });
405
- });
328
+ const setting = await promptList('設定:', [
329
+ { name: `倍速: ${chalk.cyan(config.playbackRate || 2)}x`, value: 'speed' },
330
+ { name: `無頭模式: ${chalk.cyan(config.headless ? '是' : '否')}`, value: 'headless' },
331
+ { name: `SlowMo: ${chalk.cyan(config.slowMo || 50)}ms`, value: 'slowmo' },
332
+ '__sep__',
333
+ { name: chalk.gray('⬅ 返回'), value: 'back' }
334
+ ], [
335
+ '',
336
+ chalk.white('╔' + '═'.repeat(50) + '╗'),
337
+ chalk.white('║') + chalk.bold.white(' ⚙ 設定'.padEnd(50)) + chalk.white('║'),
338
+ chalk.white('╚' + '═'.repeat(50) + '╝'),
339
+ ]);
406
340
 
407
- if (setting === 'back') return;
341
+ if (setting === '__esc__' || setting === 'back') return;
408
342
 
409
343
  if (setting === 'speed') {
410
- const { speed } = await new Promise((resolve) => {
411
- process.stdin.setRawMode(true);
412
- process.stdin.resume();
413
- const escHandler = (data) => {
414
- if (data.toString() === '\u001b') {
415
- process.stdin.removeListener('data', escHandler);
416
- process.stdin.setRawMode(false);
417
- process.stdin.pause();
418
- resolve({ speed: config.playbackRate || 2 });
419
- }
420
- };
421
- process.stdin.on('data', escHandler);
422
- inquirer.prompt([{
423
- type: 'list', name: 'speed', message: '選擇播放倍速:',
424
- choices: [
425
- { name: '1x(正常)', value: 1 },
426
- { name: '1.5x', value: 1.5 },
427
- { name: '2x(推薦)', value: 2 },
428
- { name: '4x', value: 4 },
429
- { name: '8x', value: 8 }
430
- ],
431
- default: config.playbackRate || 2
432
- }]).then(ans => {
433
- process.stdin.removeListener('data', escHandler);
434
- resolve(ans);
435
- });
436
- });
437
- config.playbackRate = speed;
438
- saveConfig(config);
439
- console.log(chalk.green(` 已設定為 ${speed}x`));
344
+ const speed = await promptList('選擇播放倍速:', [
345
+ { name: '2x', value: 2 },
346
+ { name: '1.5x', value: 1.5 },
347
+ { name: '1.25x', value: 1.25 },
348
+ { name: '1x', value: 1 },
349
+ { name: '0.75x', value: 0.75 },
350
+ ]);
351
+ if (speed !== '__esc__') {
352
+ config.playbackRate = speed;
353
+ saveConfig(config);
354
+ console.log(chalk.green(` 已設定為 ${speed}x`));
355
+ }
440
356
  } else if (setting === 'headless') {
441
- const { hl } = await new Promise((resolve) => {
442
- process.stdin.setRawMode(true);
443
- process.stdin.resume();
444
- const escHandler = (data) => {
445
- if (data.toString() === '\u001b') {
446
- process.stdin.removeListener('data', escHandler);
447
- process.stdin.setRawMode(false);
448
- process.stdin.pause();
449
- resolve({ hl: config.headless || false });
450
- }
451
- };
452
- process.stdin.on('data', escHandler);
453
- inquirer.prompt([{
454
- type: 'confirm', name: 'hl', message: '啟用無頭模式?', default: config.headless || false
455
- }]).then(ans => {
456
- process.stdin.removeListener('data', escHandler);
457
- resolve(ans);
458
- });
459
- });
460
- config.headless = hl;
461
- saveConfig(config);
357
+ const hl = await promptConfirm('啟用無頭模式?', config.headless || false);
358
+ if (hl !== null) {
359
+ config.headless = hl;
360
+ saveConfig(config);
361
+ }
462
362
  } else if (setting === 'slowmo') {
463
- const { ms } = await new Promise((resolve) => {
464
- process.stdin.setRawMode(true);
465
- process.stdin.resume();
466
- const escHandler = (data) => {
467
- if (data.toString() === '\u001b') {
468
- process.stdin.removeListener('data', escHandler);
469
- process.stdin.setRawMode(false);
470
- process.stdin.pause();
471
- resolve({ ms: config.slowMo || 50 });
472
- }
473
- };
474
- process.stdin.on('data', escHandler);
475
- inquirer.prompt([{
476
- type: 'number', name: 'ms', message: 'SlowMo 毫秒數:', default: config.slowMo || 50
477
- }]).then(ans => {
478
- process.stdin.removeListener('data', escHandler);
479
- resolve(ans);
480
- });
481
- });
482
- config.slowMo = ms;
483
- saveConfig(config);
363
+ const ms = await promptInput('SlowMo 毫秒數');
364
+ if (ms && !isNaN(parseInt(ms))) {
365
+ config.slowMo = parseInt(ms);
366
+ saveConfig(config);
367
+ }
484
368
  }
485
369
  }
486
370
 
@@ -494,22 +378,4 @@ function normalizeUrl(input) {
494
378
  return input;
495
379
  }
496
380
 
497
- async function pressAnyKey() {
498
- return new Promise((resolve) => {
499
- process.stdin.setRawMode(true);
500
- process.stdin.resume();
501
- const handler = (data) => {
502
- const str = data.toString();
503
- if (str === '\u001b' || str === '\r' || str === '\n') {
504
- process.stdin.removeListener('data', handler);
505
- process.stdin.setRawMode(false);
506
- process.stdin.pause();
507
- resolve();
508
- }
509
- };
510
- process.stdin.on('data', handler);
511
- process.stdout.write(chalk.gray('按 Enter 或 ESC 返回...'));
512
- });
513
- }
514
-
515
381
  module.exports = { mainMenu };