nekostream 2026.1.4 → 2026.2.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.
- package/dist/cli/theme.js +22 -16
- package/dist/discord.js +5 -6
- package/dist/index.js +13 -10
- package/dist/prompts-wrapper.js +127 -6
- package/dist/scrapers/providers/animevietsub.js +102 -0
- package/dist/ui.js +64 -43
- package/package.json +1 -1
- package/player-main.js +22 -7
- package/player.html +98 -46
package/dist/cli/theme.js
CHANGED
|
@@ -6,30 +6,35 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.glyphs = exports.uiText = exports.palette = void 0;
|
|
7
7
|
const chalk_1 = __importDefault(require("chalk"));
|
|
8
8
|
exports.palette = {
|
|
9
|
-
primary: '#
|
|
10
|
-
accent: '#
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
muted: '#
|
|
17
|
-
|
|
18
|
-
|
|
9
|
+
primary: '#58a6ff',
|
|
10
|
+
accent: '#db61a2',
|
|
11
|
+
cyan: '#39c5cf',
|
|
12
|
+
green: '#3fb950',
|
|
13
|
+
yellow: '#d29922',
|
|
14
|
+
red: '#f85149',
|
|
15
|
+
text: '#f0f6fc',
|
|
16
|
+
muted: '#8b949e',
|
|
17
|
+
subtle: '#6e7681',
|
|
18
|
+
border: '#30363d',
|
|
19
|
+
borderActive: '#3b82f6'
|
|
19
20
|
};
|
|
20
21
|
exports.uiText = {
|
|
21
22
|
brand: chalk_1.default.hex(exports.palette.primary).bold,
|
|
22
23
|
title: chalk_1.default.hex(exports.palette.text).bold,
|
|
23
24
|
subtitle: chalk_1.default.hex(exports.palette.muted),
|
|
24
25
|
section: chalk_1.default.hex(exports.palette.accent).bold,
|
|
25
|
-
label: chalk_1.default.hex(exports.palette.
|
|
26
|
+
label: chalk_1.default.hex(exports.palette.primary).bold,
|
|
26
27
|
value: chalk_1.default.white,
|
|
28
|
+
quietValue: chalk_1.default.hex(exports.palette.text),
|
|
27
29
|
muted: chalk_1.default.hex(exports.palette.muted),
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
subtle: chalk_1.default.hex(exports.palette.subtle),
|
|
31
|
+
success: chalk_1.default.hex(exports.palette.green),
|
|
32
|
+
warning: chalk_1.default.hex(exports.palette.yellow),
|
|
33
|
+
danger: chalk_1.default.hex(exports.palette.red),
|
|
31
34
|
border: chalk_1.default.hex(exports.palette.border),
|
|
32
|
-
|
|
35
|
+
activeBorder: chalk_1.default.hex(exports.palette.borderActive),
|
|
36
|
+
accent: chalk_1.default.hex(exports.palette.accent),
|
|
37
|
+
focus: chalk_1.default.hex(exports.palette.cyan).bold
|
|
33
38
|
};
|
|
34
39
|
exports.glyphs = {
|
|
35
40
|
topLeft: '╭',
|
|
@@ -40,7 +45,8 @@ exports.glyphs = {
|
|
|
40
45
|
vertical: '│',
|
|
41
46
|
teeLeft: '├',
|
|
42
47
|
teeRight: '┤',
|
|
43
|
-
|
|
48
|
+
cross: '┼',
|
|
49
|
+
dot: '·',
|
|
44
50
|
pointer: '›',
|
|
45
51
|
back: '←',
|
|
46
52
|
play: '▶',
|
package/dist/discord.js
CHANGED
|
@@ -20,7 +20,7 @@ let currentAnime;
|
|
|
20
20
|
let currentEpisode;
|
|
21
21
|
let isWatching = false;
|
|
22
22
|
const COMMON_BUTTONS = [
|
|
23
|
-
{ label: '
|
|
23
|
+
{ label: 'NekoStream', url: 'https://www.npmjs.com/package/nekostream' },
|
|
24
24
|
{ label: 'Tham gia Discord', url: 'https://discord.gg/Y2kq2y26pZ' }
|
|
25
25
|
];
|
|
26
26
|
const imageCache = {};
|
|
@@ -42,7 +42,7 @@ async function fetchAnimeImage(title) {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
catch (e) { }
|
|
45
|
-
return
|
|
45
|
+
return undefined;
|
|
46
46
|
}
|
|
47
47
|
async function initDiscord() {
|
|
48
48
|
const settings = (0, storage_1.loadSettings)();
|
|
@@ -93,13 +93,13 @@ async function setBrowsingPresence(details, provider, feature, animeTitle) {
|
|
|
93
93
|
if (!startTimestamp)
|
|
94
94
|
startTimestamp = new Date();
|
|
95
95
|
const stateStr = provider ? `${provider.toUpperCase()}${feature ? ` | ${feature}` : ''}` : (feature || 'NekoStream CLI');
|
|
96
|
-
const largeImageKey = animeTitle ? await fetchAnimeImage(animeTitle) :
|
|
96
|
+
const largeImageKey = animeTitle ? await fetchAnimeImage(animeTitle) : undefined;
|
|
97
97
|
const largeImageText = animeTitle || 'NekoStream';
|
|
98
98
|
safeSetActivity({
|
|
99
99
|
details: details || 'Đang lướt Menu Chính',
|
|
100
100
|
state: stateStr,
|
|
101
101
|
startTimestamp,
|
|
102
|
-
largeImageKey,
|
|
102
|
+
...(largeImageKey ? { largeImageKey } : {}),
|
|
103
103
|
largeImageText,
|
|
104
104
|
instance: false,
|
|
105
105
|
buttons: COMMON_BUTTONS
|
|
@@ -116,9 +116,8 @@ async function setWatchingPresence(animeTitle, episodeName, provider) {
|
|
|
116
116
|
details: `Đang xem: ${animeTitle}`,
|
|
117
117
|
state: `${provider.toUpperCase()} | ${episodeName}`,
|
|
118
118
|
startTimestamp,
|
|
119
|
-
largeImageKey,
|
|
119
|
+
...(largeImageKey ? { largeImageKey } : {}),
|
|
120
120
|
largeImageText: animeTitle,
|
|
121
|
-
smallImageKey: 'https://cdnjs.cloudflare.com/ajax/libs/twemoji/15.1.0/72x72/25b6.png',
|
|
122
121
|
smallImageText: 'Playing',
|
|
123
122
|
instance: false,
|
|
124
123
|
buttons: COMMON_BUTTONS
|
package/dist/index.js
CHANGED
|
@@ -48,6 +48,7 @@ const ui_1 = require("./ui");
|
|
|
48
48
|
const storage_1 = require("./storage");
|
|
49
49
|
const discord_1 = require("./discord");
|
|
50
50
|
const auth_service_1 = require("./scrapers/auth-service");
|
|
51
|
+
const NPM_PACKAGE_NAME = 'nekostream';
|
|
51
52
|
async function sleep(ms) {
|
|
52
53
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
53
54
|
}
|
|
@@ -279,7 +280,7 @@ async function openAnimeMenu(providerName, animeId) {
|
|
|
279
280
|
if (selectedAnime)
|
|
280
281
|
(0, ui_1.drawAnimeCard)(selectedAnime);
|
|
281
282
|
flushStdin();
|
|
282
|
-
const episodeChoices = [
|
|
283
|
+
const episodeChoices = [];
|
|
283
284
|
episodes.forEach((ep, idx) => {
|
|
284
285
|
const cleanTitle = (ep.title || `Episode ${ep.number}`).replace(/\r?\n|\r/g, ' ').trim();
|
|
285
286
|
episodeChoices.push({
|
|
@@ -288,7 +289,7 @@ async function openAnimeMenu(providerName, animeId) {
|
|
|
288
289
|
});
|
|
289
290
|
});
|
|
290
291
|
const { episode } = await (0, prompts_wrapper_1.default)({
|
|
291
|
-
type: '
|
|
292
|
+
type: 'grid',
|
|
292
293
|
name: 'episode',
|
|
293
294
|
message: 'Chọn tập phim (Esc: quay lại)',
|
|
294
295
|
choices: episodeChoices
|
|
@@ -561,7 +562,7 @@ async function checkUpdate() {
|
|
|
561
562
|
const basePath = isCompiled ? path_1.default.join(__dirname, '..') : __dirname;
|
|
562
563
|
const pkg = JSON.parse(fs_1.default.readFileSync(path_1.default.join(basePath, 'package.json'), 'utf-8'));
|
|
563
564
|
const currentVersion = pkg.version;
|
|
564
|
-
const res = await fetch(
|
|
565
|
+
const res = await fetch(`https://registry.npmjs.org/${NPM_PACKAGE_NAME}/latest`, { signal: AbortSignal.timeout(2000) });
|
|
565
566
|
const data = await res.json();
|
|
566
567
|
const latestVersion = data.version;
|
|
567
568
|
if (latestVersion && latestVersion !== currentVersion) {
|
|
@@ -579,18 +580,19 @@ async function checkUpdate() {
|
|
|
579
580
|
initial: true
|
|
580
581
|
});
|
|
581
582
|
if (update) {
|
|
582
|
-
const
|
|
583
|
+
const installCommand = `npm i -g ${NPM_PACKAGE_NAME}@latest`;
|
|
584
|
+
const spinner = (0, ora_1.default)(`Đang cập nhật (${installCommand})...`).start();
|
|
583
585
|
try {
|
|
584
|
-
(0, child_process_1.execSync)(
|
|
586
|
+
(0, child_process_1.execSync)(installCommand, { stdio: 'ignore' });
|
|
585
587
|
spinner.succeed(chalk_1.default.green('Đã cập nhật thành công! Vui lòng chạy lại lệnh để sử dụng bản mới.'));
|
|
586
588
|
}
|
|
587
589
|
catch (e) {
|
|
588
|
-
spinner.fail(chalk_1.default.red(
|
|
590
|
+
spinner.fail(chalk_1.default.red(`Cập nhật thất bại. Vui lòng chạy thủ công: ${installCommand}`));
|
|
589
591
|
}
|
|
590
592
|
process.exit(0);
|
|
591
593
|
}
|
|
592
594
|
else {
|
|
593
|
-
console.log(chalk_1.default.red(
|
|
595
|
+
console.log(chalk_1.default.red(`\nVui lòng chạy \`npm i -g ${NPM_PACKAGE_NAME}@latest\` để cập nhật thủ công.`));
|
|
594
596
|
process.exit(0);
|
|
595
597
|
}
|
|
596
598
|
}
|
|
@@ -612,9 +614,10 @@ async function main() {
|
|
|
612
614
|
? chalk_1.default.green(`${authStatus.userDisplayName || 'Đã đăng nhập'} (${authStatus.cookieCount || 0} cookies)`)
|
|
613
615
|
: chalk_1.default.red('Chưa đăng nhập');
|
|
614
616
|
(0, ui_1.printBanner)(`Provider: ${currentProviderName.toUpperCase()}`, 'Trang chủ');
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
617
|
+
(0, ui_1.printStatusStrip)([
|
|
618
|
+
{ label: 'Tài khoản', value: usernameDisplay },
|
|
619
|
+
{ label: 'Provider', value: chalk_1.default.green(currentProviderName) }
|
|
620
|
+
]);
|
|
618
621
|
const dynamicChoices = [
|
|
619
622
|
{ separator: 'KHÁM PHÁ' },
|
|
620
623
|
{ title: 'Tìm anime', value: 'search', description: 'Tìm theo tên phim' },
|
package/dist/prompts-wrapper.js
CHANGED
|
@@ -10,6 +10,7 @@ Object.defineProperty(exports, "Separator", { enumerable: true, get: function ()
|
|
|
10
10
|
const chalk_1 = __importDefault(require("chalk"));
|
|
11
11
|
const readline_1 = __importDefault(require("readline"));
|
|
12
12
|
const theme_1 = require("./cli/theme");
|
|
13
|
+
const text_1 = require("./cli/text");
|
|
13
14
|
function shouldOfferBack(message) {
|
|
14
15
|
return /esc|back|quay lại|thoát/i.test(message);
|
|
15
16
|
}
|
|
@@ -22,6 +23,15 @@ function normalizeMessage(message) {
|
|
|
22
23
|
.replace(/\s+/g, ' ')
|
|
23
24
|
.trim();
|
|
24
25
|
}
|
|
26
|
+
function formatSeparator(label, leadingBreak = true) {
|
|
27
|
+
const width = (0, text_1.terminalWidth)(64, 42);
|
|
28
|
+
const text = ` ${label.trim().toUpperCase()} `;
|
|
29
|
+
const fill = '─'.repeat(Math.max(0, width - (0, text_1.visibleLength)(text) - 2));
|
|
30
|
+
return `${leadingBreak ? '\n' : ''}${theme_1.uiText.section(text)}${theme_1.uiText.border(fill)}`;
|
|
31
|
+
}
|
|
32
|
+
function promptMessage(message) {
|
|
33
|
+
return `${theme_1.uiText.title(message)} ${theme_1.uiText.subtle('(Esc quay lại)')}`;
|
|
34
|
+
}
|
|
25
35
|
function mapChoices(choices, includeBack) {
|
|
26
36
|
const hasBackChoice = choices.some((choice) => {
|
|
27
37
|
const value = String(choice.value ?? '').toLowerCase();
|
|
@@ -30,7 +40,7 @@ function mapChoices(choices, includeBack) {
|
|
|
30
40
|
});
|
|
31
41
|
const mapped = choices.map((choice) => {
|
|
32
42
|
if (choice.separator) {
|
|
33
|
-
return new prompts_1.Separator(
|
|
43
|
+
return new prompts_1.Separator(formatSeparator(choice.separator));
|
|
34
44
|
}
|
|
35
45
|
return {
|
|
36
46
|
name: choice.title || String(choice.value ?? ''),
|
|
@@ -47,6 +57,102 @@ function mapChoices(choices, includeBack) {
|
|
|
47
57
|
}
|
|
48
58
|
return mapped;
|
|
49
59
|
}
|
|
60
|
+
function getGridChoices(choices) {
|
|
61
|
+
return choices
|
|
62
|
+
.filter((choice) => !choice.separator)
|
|
63
|
+
.map((choice) => ({
|
|
64
|
+
name: choice.title || String(choice.value ?? ''),
|
|
65
|
+
value: choice.value
|
|
66
|
+
}));
|
|
67
|
+
}
|
|
68
|
+
function gridLayout(choices) {
|
|
69
|
+
const width = (0, text_1.terminalWidth)(96, 58);
|
|
70
|
+
const longest = choices.reduce((max, choice) => Math.max(max, (0, text_1.visibleLength)(choice.name)), 0);
|
|
71
|
+
const columnWidth = Math.max(14, Math.min(28, longest + 2));
|
|
72
|
+
const columns = Math.max(1, Math.min(6, Math.floor(width / (columnWidth + 3)), choices.length));
|
|
73
|
+
const rows = Math.ceil(choices.length / columns);
|
|
74
|
+
return { columnWidth, columns, rows };
|
|
75
|
+
}
|
|
76
|
+
async function selectGrid(options, signal) {
|
|
77
|
+
const choices = getGridChoices(options.choices || []);
|
|
78
|
+
if (choices.length === 0)
|
|
79
|
+
return undefined;
|
|
80
|
+
let selected = 0;
|
|
81
|
+
let renderedLines = 0;
|
|
82
|
+
const message = normalizeMessage(options.message || 'Chọn');
|
|
83
|
+
const render = () => {
|
|
84
|
+
if (renderedLines > 0) {
|
|
85
|
+
readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
|
|
86
|
+
readline_1.default.cursorTo(process.stdout, 0);
|
|
87
|
+
readline_1.default.clearScreenDown(process.stdout);
|
|
88
|
+
}
|
|
89
|
+
const { columnWidth, columns, rows } = gridLayout(choices);
|
|
90
|
+
const lines = [
|
|
91
|
+
`${theme_1.uiText.focus('?')} ${promptMessage(message)}`,
|
|
92
|
+
formatSeparator('DANH SÁCH TẬP', false)
|
|
93
|
+
];
|
|
94
|
+
for (let row = 0; row < rows; row++) {
|
|
95
|
+
const cells = [];
|
|
96
|
+
for (let col = 0; col < columns; col++) {
|
|
97
|
+
const index = col * rows + row;
|
|
98
|
+
if (index >= choices.length)
|
|
99
|
+
continue;
|
|
100
|
+
const prefix = index === selected ? theme_1.uiText.focus(theme_1.glyphs.pointer) : ' ';
|
|
101
|
+
const label = (0, text_1.truncate)(choices[index].name, columnWidth - 2);
|
|
102
|
+
const cell = `${prefix} ${index === selected ? theme_1.uiText.focus(label) : theme_1.uiText.value(label)}`;
|
|
103
|
+
cells.push((0, text_1.padRight)(cell, columnWidth));
|
|
104
|
+
}
|
|
105
|
+
lines.push(cells.join(theme_1.uiText.border(` ${theme_1.glyphs.vertical} `)));
|
|
106
|
+
}
|
|
107
|
+
lines.push(chalk_1.default.gray('↑↓←→ di chuyển · Enter chọn · Esc quay lại'));
|
|
108
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
109
|
+
renderedLines = lines.length;
|
|
110
|
+
};
|
|
111
|
+
return await new Promise((resolve, reject) => {
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
process.stdin.off('keypress', onKeypress);
|
|
114
|
+
signal.removeEventListener('abort', onAbort);
|
|
115
|
+
process.stdout.write('\x1B[?25h');
|
|
116
|
+
};
|
|
117
|
+
const done = (value) => {
|
|
118
|
+
cleanup();
|
|
119
|
+
if (renderedLines > 0) {
|
|
120
|
+
readline_1.default.moveCursor(process.stdout, 0, -renderedLines);
|
|
121
|
+
readline_1.default.cursorTo(process.stdout, 0);
|
|
122
|
+
readline_1.default.clearScreenDown(process.stdout);
|
|
123
|
+
}
|
|
124
|
+
resolve(value);
|
|
125
|
+
};
|
|
126
|
+
const onAbort = () => {
|
|
127
|
+
cleanup();
|
|
128
|
+
reject(new Error('ESC_PRESSED'));
|
|
129
|
+
};
|
|
130
|
+
const onKeypress = (_str, key) => {
|
|
131
|
+
const { rows } = gridLayout(choices);
|
|
132
|
+
if (key?.name === 'escape') {
|
|
133
|
+
done(undefined);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (key?.name === 'return' || key?.name === 'enter') {
|
|
137
|
+
done(choices[selected].value);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (key?.name === 'up')
|
|
141
|
+
selected = Math.max(0, selected - 1);
|
|
142
|
+
if (key?.name === 'down')
|
|
143
|
+
selected = Math.min(choices.length - 1, selected + 1);
|
|
144
|
+
if (key?.name === 'left')
|
|
145
|
+
selected = Math.max(0, selected - rows);
|
|
146
|
+
if (key?.name === 'right')
|
|
147
|
+
selected = Math.min(choices.length - 1, selected + rows);
|
|
148
|
+
render();
|
|
149
|
+
};
|
|
150
|
+
signal.addEventListener('abort', onAbort);
|
|
151
|
+
process.stdin.on('keypress', onKeypress);
|
|
152
|
+
process.stdout.write('\x1B[?25l');
|
|
153
|
+
render();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
50
156
|
async function prompts(options) {
|
|
51
157
|
const ac = new AbortController();
|
|
52
158
|
if (process.stdin.isTTY) {
|
|
@@ -57,21 +163,34 @@ async function prompts(options) {
|
|
|
57
163
|
ac.abort(new Error('ESC_PRESSED'));
|
|
58
164
|
}
|
|
59
165
|
};
|
|
60
|
-
|
|
166
|
+
if (options.type !== 'grid') {
|
|
167
|
+
process.stdin.on('keypress', onKeypress);
|
|
168
|
+
}
|
|
61
169
|
try {
|
|
170
|
+
if (options.type === 'grid') {
|
|
171
|
+
const result = await selectGrid(options, ac.signal);
|
|
172
|
+
return result ? { [options.name]: result } : {};
|
|
173
|
+
}
|
|
62
174
|
if (options.type === 'select') {
|
|
63
175
|
const message = normalizeMessage(options.message || 'Chọn');
|
|
64
176
|
const includeBack = shouldOfferBack(options.message || '');
|
|
65
177
|
const choices = mapChoices(options.choices || [], includeBack);
|
|
66
178
|
const result = await (0, prompts_1.select)({
|
|
67
|
-
message:
|
|
179
|
+
message: promptMessage(message),
|
|
68
180
|
choices,
|
|
69
181
|
pageSize: options.pageSize || 15,
|
|
70
182
|
loop: false,
|
|
71
183
|
theme: {
|
|
72
184
|
helpMode: 'always',
|
|
185
|
+
prefix: {
|
|
186
|
+
idle: theme_1.uiText.focus('?'),
|
|
187
|
+
done: theme_1.uiText.success('>')
|
|
188
|
+
},
|
|
189
|
+
icon: {
|
|
190
|
+
cursor: theme_1.uiText.focus(theme_1.glyphs.pointer)
|
|
191
|
+
},
|
|
73
192
|
style: {
|
|
74
|
-
keysHelpTip: () => chalk_1.default.gray('↑↓ di chuyển
|
|
193
|
+
keysHelpTip: () => chalk_1.default.gray('↑↓ di chuyển · Enter chọn · Esc quay lại')
|
|
75
194
|
}
|
|
76
195
|
}
|
|
77
196
|
}, { signal: ac.signal, clearPromptOnDone: true });
|
|
@@ -81,7 +200,7 @@ async function prompts(options) {
|
|
|
81
200
|
}
|
|
82
201
|
if (options.type === 'text') {
|
|
83
202
|
const result = await (0, prompts_1.input)({
|
|
84
|
-
message:
|
|
203
|
+
message: promptMessage(normalizeMessage(options.message || 'Nhập')),
|
|
85
204
|
default: options.initial
|
|
86
205
|
}, { signal: ac.signal, clearPromptOnDone: true });
|
|
87
206
|
return { [options.name]: result };
|
|
@@ -101,7 +220,9 @@ async function prompts(options) {
|
|
|
101
220
|
throw err;
|
|
102
221
|
}
|
|
103
222
|
finally {
|
|
104
|
-
|
|
223
|
+
if (options.type !== 'grid') {
|
|
224
|
+
process.stdin.off('keypress', onKeypress);
|
|
225
|
+
}
|
|
105
226
|
}
|
|
106
227
|
return {};
|
|
107
228
|
}
|
|
@@ -49,6 +49,13 @@ const logger_1 = require("../../logger");
|
|
|
49
49
|
class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
50
50
|
name = 'AnimeVietsub';
|
|
51
51
|
baseUrl = 'https://animevietsub.site';
|
|
52
|
+
static knownBaseUrls = [
|
|
53
|
+
'https://animevietsub.love',
|
|
54
|
+
'https://animevietsub.site',
|
|
55
|
+
'https://animevietsub.fan',
|
|
56
|
+
'https://animevietsub.bz',
|
|
57
|
+
'https://animevietsub.tv'
|
|
58
|
+
];
|
|
52
59
|
// Stable Chrome 124 profile to align with the player's webview user agent
|
|
53
60
|
stableProfile = {
|
|
54
61
|
ua: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36',
|
|
@@ -57,6 +64,78 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
|
57
64
|
secChUaPlatform: '"Windows"',
|
|
58
65
|
acceptLanguage: 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7',
|
|
59
66
|
};
|
|
67
|
+
normalizeBaseUrl(value) {
|
|
68
|
+
const url = value.startsWith('http') ? value : `https://${value}`;
|
|
69
|
+
return url.replace(/\/$/, '');
|
|
70
|
+
}
|
|
71
|
+
isAnimeVietsubUrl(value) {
|
|
72
|
+
try {
|
|
73
|
+
return new URL(value).hostname.toLowerCase().includes('animevietsub');
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
rememberWorkingDomain(finalUrl, sourceUrl) {
|
|
80
|
+
if (!this.isAnimeVietsubUrl(finalUrl))
|
|
81
|
+
return;
|
|
82
|
+
const finalBaseUrl = new URL(finalUrl).origin;
|
|
83
|
+
const currentBaseUrl = this.normalizeBaseUrl(this.baseUrl);
|
|
84
|
+
if (finalBaseUrl === currentBaseUrl)
|
|
85
|
+
return;
|
|
86
|
+
this.baseUrl = finalBaseUrl;
|
|
87
|
+
try {
|
|
88
|
+
const settings = (0, storage_1.loadSettings)();
|
|
89
|
+
(0, storage_1.saveSettings)({
|
|
90
|
+
providerDomains: {
|
|
91
|
+
...(settings.providerDomains || {}),
|
|
92
|
+
animevietsub: finalBaseUrl
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
(0, logger_1.debugLog)(`[AnimeVietsub] Domain updated: ${sourceUrl || currentBaseUrl} -> ${finalBaseUrl}`);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
(0, logger_1.debugWarn)('[AnimeVietsub] Could not save discovered domain:', error);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
getAlternateUrls(originalUrl) {
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = new URL(originalUrl);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return [];
|
|
108
|
+
}
|
|
109
|
+
const path = `${parsed.pathname}${parsed.search}`;
|
|
110
|
+
const currentBaseUrl = this.normalizeBaseUrl(this.baseUrl);
|
|
111
|
+
const candidates = [
|
|
112
|
+
currentBaseUrl,
|
|
113
|
+
...AnimeVietsubProvider.knownBaseUrls.map((url) => this.normalizeBaseUrl(url))
|
|
114
|
+
];
|
|
115
|
+
return [...new Set(candidates)]
|
|
116
|
+
.filter((baseUrl) => baseUrl !== parsed.origin)
|
|
117
|
+
.map((baseUrl) => `${baseUrl}${path}`);
|
|
118
|
+
}
|
|
119
|
+
async fetchHtmlFromAlternateDomains(originalUrl, options) {
|
|
120
|
+
if (!this.isAnimeVietsubUrl(originalUrl))
|
|
121
|
+
return null;
|
|
122
|
+
for (const alternateUrl of this.getAlternateUrls(originalUrl)) {
|
|
123
|
+
try {
|
|
124
|
+
(0, logger_1.debugWarn)(`[AnimeVietsub] Trying alternate domain: ${alternateUrl}`);
|
|
125
|
+
const html = await this.fetchHtml(alternateUrl, {
|
|
126
|
+
...options,
|
|
127
|
+
retries: 0,
|
|
128
|
+
allowDomainFallback: false
|
|
129
|
+
});
|
|
130
|
+
this.rememberWorkingDomain(alternateUrl, originalUrl);
|
|
131
|
+
return html;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
(0, logger_1.debugWarn)(`[AnimeVietsub] Alternate domain failed: ${alternateUrl}`, error);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
60
139
|
// User endpoints omitted in standalone CLI
|
|
61
140
|
toMediaProxyUrl(sourceUrl, referer) {
|
|
62
141
|
const query = new URLSearchParams({
|
|
@@ -125,6 +204,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
|
125
204
|
const timeoutMs = options.timeoutMs ?? 20000;
|
|
126
205
|
const retries = options.retries ?? 2;
|
|
127
206
|
const useBrowserFallback = options.useBrowserFallback ?? true;
|
|
207
|
+
const allowDomainFallback = options.allowDomainFallback ?? true;
|
|
128
208
|
// --- Playwright-first for known-blocked hosts ---
|
|
129
209
|
// Skip the fetch loop entirely and go straight to Playwright for hosts
|
|
130
210
|
// that consistently return 520/403 on raw fetch() calls.
|
|
@@ -174,6 +254,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
|
174
254
|
if (!response.ok) {
|
|
175
255
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
176
256
|
}
|
|
257
|
+
this.rememberWorkingDomain(response.url, url);
|
|
177
258
|
const html = await response.text();
|
|
178
259
|
// Botasaurus: CloudflareDetectionException — detect CF challenge on 200 response
|
|
179
260
|
if ((0, fingerprint_1.isCloudflareChallengePage)(html)) {
|
|
@@ -216,16 +297,36 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
|
216
297
|
}
|
|
217
298
|
if (error instanceof Error) {
|
|
218
299
|
if (this.isTimeoutLikeError(error)) {
|
|
300
|
+
if (allowDomainFallback) {
|
|
301
|
+
const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
|
|
302
|
+
if (fallbackHtml)
|
|
303
|
+
return fallbackHtml;
|
|
304
|
+
}
|
|
219
305
|
throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
|
|
220
306
|
}
|
|
307
|
+
if (allowDomainFallback) {
|
|
308
|
+
const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
|
|
309
|
+
if (fallbackHtml)
|
|
310
|
+
return fallbackHtml;
|
|
311
|
+
}
|
|
221
312
|
throw new Error(`Lỗi kết nối: ${error.message}`);
|
|
222
313
|
}
|
|
223
314
|
throw error;
|
|
224
315
|
}
|
|
225
316
|
}
|
|
226
317
|
if (lastError instanceof Error && this.isTimeoutLikeError(lastError)) {
|
|
318
|
+
if (allowDomainFallback) {
|
|
319
|
+
const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
|
|
320
|
+
if (fallbackHtml)
|
|
321
|
+
return fallbackHtml;
|
|
322
|
+
}
|
|
227
323
|
throw new Error('Yêu cầu quá lâu không phản hồi. Vui lòng thử lại.');
|
|
228
324
|
}
|
|
325
|
+
if (allowDomainFallback) {
|
|
326
|
+
const fallbackHtml = await this.fetchHtmlFromAlternateDomains(url, options);
|
|
327
|
+
if (fallbackHtml)
|
|
328
|
+
return fallbackHtml;
|
|
329
|
+
}
|
|
229
330
|
throw new Error('Lỗi kết nối không xác định');
|
|
230
331
|
}
|
|
231
332
|
/**
|
|
@@ -340,6 +441,7 @@ class AnimeVietsubProvider extends base_1.BaseScraper {
|
|
|
340
441
|
}
|
|
341
442
|
// Extra settle time
|
|
342
443
|
await page.waitForTimeout(1000);
|
|
444
|
+
this.rememberWorkingDomain(page.url(), url);
|
|
343
445
|
const html = await page.content();
|
|
344
446
|
if (!html || html.length < 100) {
|
|
345
447
|
throw new Error('Playwright trả về nội dung trống');
|
package/dist/ui.js
CHANGED
|
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.clearScreen = clearScreen;
|
|
40
40
|
exports.printBanner = printBanner;
|
|
41
|
+
exports.printStatusStrip = printStatusStrip;
|
|
41
42
|
exports.drawAnimeCard = drawAnimeCard;
|
|
42
43
|
exports.drawAnimeInfoCard = drawAnimeInfoCard;
|
|
43
44
|
exports.printHint = printHint;
|
|
@@ -81,80 +82,100 @@ function getVersion() {
|
|
|
81
82
|
return 'unknown';
|
|
82
83
|
}
|
|
83
84
|
}
|
|
84
|
-
function borderLine(width, left, right) {
|
|
85
|
-
|
|
85
|
+
function borderLine(width, left, right, active = false) {
|
|
86
|
+
const color = active ? theme_1.uiText.activeBorder : theme_1.uiText.border;
|
|
87
|
+
return color(`${left}${theme_1.glyphs.horizontal.repeat(width - 2)}${right}`);
|
|
86
88
|
}
|
|
87
|
-
function boxedLine(content, width) {
|
|
88
|
-
|
|
89
|
+
function boxedLine(content, width, active = false) {
|
|
90
|
+
const left = active ? theme_1.uiText.activeBorder(theme_1.glyphs.vertical) : theme_1.uiText.border(theme_1.glyphs.vertical);
|
|
91
|
+
const right = theme_1.uiText.border(theme_1.glyphs.vertical);
|
|
92
|
+
return `${left} ${(0, text_1.padRight)(content, width - 4)} ${right}`;
|
|
93
|
+
}
|
|
94
|
+
function innerRule(label, width) {
|
|
95
|
+
const text = label ? ` ${label} ` : '';
|
|
96
|
+
const fill = theme_1.glyphs.horizontal.repeat(Math.max(0, width - (0, text_1.visibleLength)(text)));
|
|
97
|
+
return theme_1.uiText.border(`${text}${fill}`);
|
|
98
|
+
}
|
|
99
|
+
function renderMeta(label, value) {
|
|
100
|
+
return `${theme_1.uiText.label(label)} ${theme_1.uiText.value((0, text_1.cleanInline)(value))}`;
|
|
101
|
+
}
|
|
102
|
+
function renderMetadataLines(items, innerWidth) {
|
|
103
|
+
const divider = chalk_1.default.hex(theme_1.palette.border)(` ${theme_1.glyphs.vertical} `);
|
|
104
|
+
const pieces = items.map((item) => renderMeta(item.label, item.value));
|
|
105
|
+
return (0, text_1.wrapText)(pieces.join(divider), innerWidth);
|
|
106
|
+
}
|
|
107
|
+
function printPanelHeader(anime, width, innerWidth) {
|
|
108
|
+
console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width, true));
|
|
109
|
+
if (anime.titleAlt) {
|
|
110
|
+
console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width, true));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function animeMetadata(anime) {
|
|
114
|
+
const genres = anime.genres && anime.genres.length > 0 ? anime.genres.join(', ') : undefined;
|
|
115
|
+
return [
|
|
116
|
+
{ label: 'Tập', value: anime.episodeCount },
|
|
117
|
+
{ label: 'Năm', value: anime.year && anime.year > 1900 ? anime.year : undefined },
|
|
118
|
+
{ label: 'Trạng thái', value: anime.status },
|
|
119
|
+
{ label: 'Thể loại', value: genres }
|
|
120
|
+
];
|
|
89
121
|
}
|
|
90
122
|
function printBanner(title, subtitle) {
|
|
91
|
-
const width = (0, text_1.terminalWidth)(
|
|
123
|
+
const width = (0, text_1.terminalWidth)(92, 58);
|
|
92
124
|
const version = getVersion();
|
|
93
|
-
const brand = `${theme_1.uiText.brand('NekoStream')} ${theme_1.uiText.
|
|
94
|
-
const heading = title ? theme_1.uiText.title(title) : theme_1.uiText.title('
|
|
125
|
+
const brand = `${theme_1.uiText.brand('NekoStream')} ${theme_1.uiText.subtle(`v${version}`)}`;
|
|
126
|
+
const heading = title ? theme_1.uiText.title(title) : theme_1.uiText.title('Terminal');
|
|
95
127
|
const sub = subtitle ? theme_1.uiText.subtitle(subtitle) : theme_1.uiText.subtitle('Fast anime browsing from your terminal');
|
|
96
128
|
console.log('');
|
|
97
|
-
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
|
|
98
|
-
console.log(boxedLine(`${brand} ${theme_1.uiText.
|
|
129
|
+
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
|
|
130
|
+
console.log(boxedLine(`${brand} ${theme_1.uiText.subtle(theme_1.glyphs.dot)} ${heading}`, width, true));
|
|
99
131
|
console.log(boxedLine(sub, width));
|
|
100
132
|
console.log(borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight));
|
|
101
133
|
console.log('');
|
|
102
134
|
}
|
|
103
|
-
function
|
|
104
|
-
|
|
135
|
+
function printStatusStrip(items) {
|
|
136
|
+
const width = (0, text_1.terminalWidth)(92, 58);
|
|
137
|
+
const innerWidth = width - 4;
|
|
138
|
+
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
|
|
139
|
+
for (const line of renderMetadataLines(items, innerWidth)) {
|
|
140
|
+
console.log(boxedLine(line, width, true));
|
|
141
|
+
}
|
|
142
|
+
console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
|
|
105
143
|
}
|
|
106
144
|
function drawAnimeCard(anime) {
|
|
107
|
-
const width = (0, text_1.terminalWidth)(
|
|
145
|
+
const width = (0, text_1.terminalWidth)(92, 58);
|
|
108
146
|
const innerWidth = width - 4;
|
|
109
147
|
const description = (0, text_1.cleanDescription)(anime.descriptionVi) ||
|
|
110
148
|
(0, text_1.cleanDescription)(anime.description) ||
|
|
111
149
|
(0, text_1.cleanDescription)(anime.descriptionEn);
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
renderMeta('Tập:', anime.episodeCount),
|
|
115
|
-
renderMeta('Năm:', anime.year && anime.year > 1900 ? anime.year : undefined),
|
|
116
|
-
renderMeta('Trạng thái:', anime.status),
|
|
117
|
-
renderMeta('Thể loại:', genres)
|
|
118
|
-
];
|
|
119
|
-
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
|
|
120
|
-
console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width));
|
|
121
|
-
if (anime.titleAlt) {
|
|
122
|
-
console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width));
|
|
123
|
-
}
|
|
150
|
+
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
|
|
151
|
+
printPanelHeader(anime, width, innerWidth);
|
|
124
152
|
console.log(borderLine(width, theme_1.glyphs.teeLeft, theme_1.glyphs.teeRight));
|
|
125
|
-
for (const line of (
|
|
153
|
+
for (const line of renderMetadataLines(animeMetadata(anime), innerWidth)) {
|
|
126
154
|
console.log(boxedLine(line, width));
|
|
127
155
|
}
|
|
128
156
|
if (description) {
|
|
129
157
|
console.log(boxedLine('', width));
|
|
130
|
-
|
|
131
|
-
|
|
158
|
+
console.log(boxedLine(innerRule('Mô tả', innerWidth), width));
|
|
159
|
+
for (const line of (0, text_1.wrapText)(description, innerWidth)) {
|
|
132
160
|
console.log(boxedLine(theme_1.uiText.muted(line), width));
|
|
133
|
-
}
|
|
161
|
+
}
|
|
134
162
|
}
|
|
135
163
|
console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
|
|
136
164
|
}
|
|
137
165
|
function drawAnimeInfoCard(anime, selectedEpisode) {
|
|
138
|
-
const width = (0, text_1.terminalWidth)(
|
|
166
|
+
const width = (0, text_1.terminalWidth)(92, 58);
|
|
139
167
|
const innerWidth = width - 4;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
renderMeta('Tập:', anime.episodeCount),
|
|
143
|
-
renderMeta('Năm:', anime.year && anime.year > 1900 ? anime.year : undefined),
|
|
144
|
-
renderMeta('Trạng thái:', anime.status),
|
|
145
|
-
renderMeta('Thể loại:', genres)
|
|
146
|
-
];
|
|
147
|
-
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight));
|
|
148
|
-
console.log(boxedLine(theme_1.uiText.accent((0, text_1.truncate)(anime.title, innerWidth)), width));
|
|
149
|
-
if (anime.titleAlt) {
|
|
150
|
-
console.log(boxedLine(theme_1.uiText.muted((0, text_1.truncate)(anime.titleAlt, innerWidth)), width));
|
|
151
|
-
}
|
|
168
|
+
console.log(borderLine(width, theme_1.glyphs.topLeft, theme_1.glyphs.topRight, true));
|
|
169
|
+
printPanelHeader(anime, width, innerWidth);
|
|
152
170
|
console.log(borderLine(width, theme_1.glyphs.teeLeft, theme_1.glyphs.teeRight));
|
|
153
|
-
for (const line of (
|
|
171
|
+
for (const line of renderMetadataLines(animeMetadata(anime), innerWidth)) {
|
|
154
172
|
console.log(boxedLine(line, width));
|
|
155
173
|
}
|
|
156
174
|
if (selectedEpisode) {
|
|
157
|
-
console.log(boxedLine(
|
|
175
|
+
console.log(boxedLine('', width));
|
|
176
|
+
for (const line of (0, text_1.wrapText)(`${renderMeta('Đang chọn', selectedEpisode)}`, innerWidth)) {
|
|
177
|
+
console.log(boxedLine(line, width));
|
|
178
|
+
}
|
|
158
179
|
}
|
|
159
180
|
console.log(`${borderLine(width, theme_1.glyphs.bottomLeft, theme_1.glyphs.bottomRight)}\n`);
|
|
160
181
|
}
|
package/package.json
CHANGED
package/player-main.js
CHANGED
|
@@ -4,13 +4,28 @@ const path = require('path')
|
|
|
4
4
|
|
|
5
5
|
let mainWindow = null
|
|
6
6
|
|
|
7
|
-
function logToRenderer(msg) {
|
|
8
|
-
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
|
|
9
|
-
mainWindow.webContents.send('main-log', msg);
|
|
10
|
-
}
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
|
|
7
|
+
function logToRenderer(msg) {
|
|
8
|
+
if (mainWindow && !mainWindow.isDestroyed() && mainWindow.webContents) {
|
|
9
|
+
mainWindow.webContents.send('main-log', msg);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
ipcMain.on('player:minimize', () => {
|
|
14
|
+
if (mainWindow && !mainWindow.isDestroyed()) {
|
|
15
|
+
mainWindow.minimize()
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
ipcMain.on('player:toggle-maximize', () => {
|
|
20
|
+
if (!mainWindow || mainWindow.isDestroyed()) return
|
|
21
|
+
if (mainWindow.isMaximized()) {
|
|
22
|
+
mainWindow.unmaximize()
|
|
23
|
+
} else {
|
|
24
|
+
mainWindow.maximize()
|
|
25
|
+
}
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function createWindow() {
|
|
14
29
|
const streamData = process.env.NEKOSTREAM_CLI_STREAM
|
|
15
30
|
if (!streamData) {
|
|
16
31
|
console.error('No stream data provided.')
|
package/player.html
CHANGED
|
@@ -14,48 +14,82 @@
|
|
|
14
14
|
overflow: hidden;
|
|
15
15
|
font-family: sans-serif;
|
|
16
16
|
}
|
|
17
|
-
#player-container {
|
|
18
|
-
width: 100%;
|
|
19
|
-
height: 100%;
|
|
20
|
-
}
|
|
17
|
+
#player-container {
|
|
18
|
+
width: 100%;
|
|
19
|
+
height: 100%;
|
|
20
|
+
}
|
|
21
21
|
webview {
|
|
22
22
|
width: 100%;
|
|
23
23
|
height: 100%;
|
|
24
24
|
border: none;
|
|
25
25
|
}
|
|
26
|
-
#
|
|
27
|
-
position: absolute;
|
|
28
|
-
top:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
-webkit-app-region:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
26
|
+
#top-nav {
|
|
27
|
+
position: absolute;
|
|
28
|
+
top: 0;
|
|
29
|
+
left: 0;
|
|
30
|
+
right: 0;
|
|
31
|
+
height: 44px;
|
|
32
|
+
z-index: 10000;
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
justify-content: space-between;
|
|
36
|
+
background: linear-gradient(to bottom, rgba(5, 8, 13, 0.82), rgba(5, 8, 13, 0.22), rgba(5, 8, 13, 0));
|
|
37
|
+
color: rgba(255, 255, 255, 0.92);
|
|
38
|
+
-webkit-app-region: drag;
|
|
39
|
+
user-select: none;
|
|
40
|
+
pointer-events: auto;
|
|
41
|
+
}
|
|
42
|
+
.nav-title {
|
|
43
|
+
min-width: 0;
|
|
44
|
+
padding-left: 16px;
|
|
45
|
+
font-size: 13px;
|
|
46
|
+
font-weight: 600;
|
|
47
|
+
letter-spacing: 0.01em;
|
|
48
|
+
color: rgba(255, 255, 255, 0.88);
|
|
49
|
+
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.55);
|
|
50
|
+
overflow: hidden;
|
|
51
|
+
text-overflow: ellipsis;
|
|
52
|
+
white-space: nowrap;
|
|
53
|
+
}
|
|
54
|
+
.window-controls {
|
|
55
|
+
display: flex;
|
|
56
|
+
height: 32px;
|
|
57
|
+
margin-right: 10px;
|
|
58
|
+
overflow: hidden;
|
|
59
|
+
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
60
|
+
border-radius: 6px;
|
|
61
|
+
background: rgba(8, 12, 20, 0.38);
|
|
62
|
+
backdrop-filter: blur(10px);
|
|
63
|
+
pointer-events: auto;
|
|
64
|
+
-webkit-app-region: no-drag;
|
|
65
|
+
}
|
|
66
|
+
.window-btn {
|
|
67
|
+
width: 42px;
|
|
68
|
+
height: 32px;
|
|
69
|
+
display: inline-flex;
|
|
70
|
+
align-items: center;
|
|
71
|
+
justify-content: center;
|
|
72
|
+
border: 0;
|
|
73
|
+
border-right: 1px solid rgba(255, 255, 255, 0.1);
|
|
74
|
+
background: transparent;
|
|
75
|
+
color: rgba(255, 255, 255, 0.82);
|
|
76
|
+
font-size: 16px;
|
|
77
|
+
line-height: 1;
|
|
78
|
+
cursor: pointer;
|
|
79
|
+
transition: background 0.15s ease, color 0.15s ease;
|
|
80
|
+
}
|
|
81
|
+
.window-btn:last-child {
|
|
82
|
+
border-right: 0;
|
|
83
|
+
}
|
|
84
|
+
.window-btn:hover {
|
|
85
|
+
background: rgba(255, 255, 255, 0.14);
|
|
86
|
+
color: #fff;
|
|
87
|
+
}
|
|
88
|
+
.window-btn.close:hover {
|
|
89
|
+
background: #e5484d;
|
|
90
|
+
color: #fff;
|
|
91
|
+
}
|
|
92
|
+
#loading-overlay {
|
|
59
93
|
position: absolute;
|
|
60
94
|
top: 0;
|
|
61
95
|
left: 0;
|
|
@@ -92,10 +126,16 @@
|
|
|
92
126
|
<script src="./node_modules/hls.js/dist/hls.min.js"></script>
|
|
93
127
|
<script src="./node_modules/artplayer/dist/artplayer.js"></script>
|
|
94
128
|
</head>
|
|
95
|
-
<body>
|
|
96
|
-
<
|
|
97
|
-
|
|
98
|
-
|
|
129
|
+
<body>
|
|
130
|
+
<header id="top-nav">
|
|
131
|
+
<div class="nav-title" id="nav-title">NekoStream Player</div>
|
|
132
|
+
<div class="window-controls">
|
|
133
|
+
<button class="window-btn" id="window-minimize" title="Minimize">-</button>
|
|
134
|
+
<button class="window-btn" id="window-maximize" title="Maximize">□</button>
|
|
135
|
+
<button class="window-btn close" id="window-close" title="Close">×</button>
|
|
136
|
+
</div>
|
|
137
|
+
</header>
|
|
138
|
+
<div id="loading-overlay">
|
|
99
139
|
<div class="spinner"></div>
|
|
100
140
|
<div class="loading-text">Đang tải và làm sạch trình phát...</div>
|
|
101
141
|
</div>
|
|
@@ -103,9 +143,19 @@
|
|
|
103
143
|
|
|
104
144
|
<script>
|
|
105
145
|
const { ipcRenderer } = require('electron');
|
|
106
|
-
ipcRenderer.on('main-log', (e, msg) => {
|
|
107
|
-
console.log('[Main Process]', msg);
|
|
108
|
-
});
|
|
146
|
+
ipcRenderer.on('main-log', (e, msg) => {
|
|
147
|
+
console.log('[Main Process]', msg);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
document.getElementById('window-minimize').addEventListener('click', () => {
|
|
151
|
+
ipcRenderer.send('player:minimize');
|
|
152
|
+
});
|
|
153
|
+
document.getElementById('window-maximize').addEventListener('click', () => {
|
|
154
|
+
ipcRenderer.send('player:toggle-maximize');
|
|
155
|
+
});
|
|
156
|
+
document.getElementById('window-close').addEventListener('click', () => {
|
|
157
|
+
window.close();
|
|
158
|
+
});
|
|
109
159
|
|
|
110
160
|
// Keyboard shortcut: Escape to close
|
|
111
161
|
document.addEventListener('keydown', (e) => {
|
|
@@ -113,8 +163,10 @@
|
|
|
113
163
|
});
|
|
114
164
|
|
|
115
165
|
|
|
116
|
-
window.initPlayer = function(streamInfo, baseDir, cleanUA) {
|
|
117
|
-
const container = document.getElementById('player-container');
|
|
166
|
+
window.initPlayer = function(streamInfo, baseDir, cleanUA) {
|
|
167
|
+
const container = document.getElementById('player-container');
|
|
168
|
+
const navTitle = document.getElementById('nav-title');
|
|
169
|
+
navTitle.textContent = streamInfo.provider ? `NekoStream · ${streamInfo.provider}` : 'NekoStream Player';
|
|
118
170
|
|
|
119
171
|
if (streamInfo.type === 'iframe') {
|
|
120
172
|
const overlay = document.getElementById('loading-overlay');
|