komado 0.1.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 +208 -0
- package/dist/app.js +59 -0
- package/dist/cli.js +196 -0
- package/dist/components/List.js +41 -0
- package/dist/components/screens/ContinueScreen.js +66 -0
- package/dist/components/screens/HomeScreen.js +59 -0
- package/dist/components/screens/LibraryScreen.js +69 -0
- package/dist/components/screens/LoginScreen.js +85 -0
- package/dist/components/screens/MangaScreen.js +107 -0
- package/dist/components/screens/ReaderScreen.js +195 -0
- package/dist/components/screens/SearchScreen.js +111 -0
- package/dist/components/screens/SettingsScreen.js +144 -0
- package/dist/components/ui.js +33 -0
- package/dist/config.js +51 -0
- package/dist/domain/shape.js +44 -0
- package/dist/hooks/useStdoutDimensions.js +17 -0
- package/dist/lib/AppError.js +37 -0
- package/dist/lib/cache.js +54 -0
- package/dist/lib/catchAsync.js +12 -0
- package/dist/lib/envelope.js +15 -0
- package/dist/lib/fetchWithBackoff.js +65 -0
- package/dist/lib/logger.js +41 -0
- package/dist/lib/natsort.js +7 -0
- package/dist/lib/text.js +20 -0
- package/dist/render/chafa.js +56 -0
- package/dist/render/detect.js +86 -0
- package/dist/render/halfblock.js +42 -0
- package/dist/render/image.js +23 -0
- package/dist/render/sixel.js +88 -0
- package/dist/sixel-reader.js +309 -0
- package/dist/sources/index.js +17 -0
- package/dist/sources/local/archive.js +68 -0
- package/dist/sources/local/index.js +147 -0
- package/dist/sources/mangadex/auth.js +102 -0
- package/dist/sources/mangadex/client.js +76 -0
- package/dist/sources/mangadex/index.js +156 -0
- package/dist/sources/mangadex/normalize.js +54 -0
- package/dist/state/store.js +91 -0
- package/dist/ui-context.js +11 -0
- package/package.json +50 -0
- package/src/app.js +73 -0
- package/src/cli.js +218 -0
- package/src/components/List.js +60 -0
- package/src/components/screens/ContinueScreen.js +73 -0
- package/src/components/screens/HomeScreen.js +54 -0
- package/src/components/screens/LibraryScreen.js +79 -0
- package/src/components/screens/LoginScreen.js +92 -0
- package/src/components/screens/MangaScreen.js +125 -0
- package/src/components/screens/ReaderScreen.js +230 -0
- package/src/components/screens/SearchScreen.js +123 -0
- package/src/components/screens/SettingsScreen.js +146 -0
- package/src/components/ui.js +42 -0
- package/src/config.js +49 -0
- package/src/domain/shape.js +47 -0
- package/src/hooks/useStdoutDimensions.js +19 -0
- package/src/lib/AppError.js +26 -0
- package/src/lib/cache.js +57 -0
- package/src/lib/catchAsync.js +12 -0
- package/src/lib/envelope.js +14 -0
- package/src/lib/fetchWithBackoff.js +74 -0
- package/src/lib/logger.js +41 -0
- package/src/lib/natsort.js +7 -0
- package/src/lib/text.js +18 -0
- package/src/render/chafa.js +64 -0
- package/src/render/detect.js +112 -0
- package/src/render/halfblock.js +46 -0
- package/src/render/image.js +24 -0
- package/src/render/sixel.js +141 -0
- package/src/sixel-reader.js +359 -0
- package/src/sources/index.js +17 -0
- package/src/sources/local/archive.js +74 -0
- package/src/sources/local/index.js +155 -0
- package/src/sources/mangadex/auth.js +125 -0
- package/src/sources/mangadex/client.js +83 -0
- package/src/sources/mangadex/index.js +166 -0
- package/src/sources/mangadex/normalize.js +70 -0
- package/src/state/store.js +90 -0
- package/src/ui-context.js +12 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import sharp from 'sharp';
|
|
4
|
+
import { withTempImage } from './chafa.js';
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
// Fallback cell pixel size when the terminal didn't report one.
|
|
9
|
+
const DEFAULT_CELL_W = 10;
|
|
10
|
+
const DEFAULT_CELL_H = 20;
|
|
11
|
+
|
|
12
|
+
// Encode an already-correctly-sized image to sixel at its NATIVE pixel
|
|
13
|
+
// resolution. --exact-size + font-ratio 1/1 makes chafa emit ~1 image px → 1
|
|
14
|
+
// sixel px, so the displayed size is exactly what we sized the image to — no
|
|
15
|
+
// dependence on chafa guessing the terminal's cell size through a pipe.
|
|
16
|
+
export async function encodePixels(buffer, { format = 'sixel' } = {}) {
|
|
17
|
+
const { stdout } = await withTempImage(buffer, (file) =>
|
|
18
|
+
execFileAsync(
|
|
19
|
+
'chafa',
|
|
20
|
+
['--format', format, '--exact-size', 'on', '--font-ratio', '1/1', '--animate', 'off', file],
|
|
21
|
+
{ maxBuffer: 256 * 1024 * 1024, encoding: 'buffer' },
|
|
22
|
+
),
|
|
23
|
+
);
|
|
24
|
+
return stdout; // Buffer of sixel/kitty bytes
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Scale a page to the full viewport width ONCE. In 'width' mode the same scaled
|
|
28
|
+
// page is windowed for every vertical scroll position, so doing the decode+resize
|
|
29
|
+
// here (and caching the result in the caller) keeps it off the per-keypress path.
|
|
30
|
+
// Returns { buffer, width, height } with the real scaled pixel dimensions.
|
|
31
|
+
export async function scalePage(buffer, { cols, cellW }) {
|
|
32
|
+
const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
|
|
33
|
+
const scaled = await sharp(buffer).resize({ width: viewW }).png().toBuffer();
|
|
34
|
+
const meta = await sharp(scaled).metadata();
|
|
35
|
+
return { buffer: scaled, width: meta.width || viewW, height: meta.height || 0 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Resize/crop a page to the exact viewport pixel rectangle.
|
|
39
|
+
// mode 'fit' → whole page fits inside cols×rows cells
|
|
40
|
+
// mode 'width' → full terminal width, vertical window at cell offset `scroll`
|
|
41
|
+
// Pass a pre-scaled page (`scaled` from scalePage) in 'width' mode to skip the
|
|
42
|
+
// per-scroll resize; without it the page is scaled inline.
|
|
43
|
+
// Returns { buffer, maxScroll, scroll, imageRows } — scroll clamped to range,
|
|
44
|
+
// imageRows = the rendered image's height in cells (so the caller can erase any
|
|
45
|
+
// rows below it instead of clearing the whole screen).
|
|
46
|
+
export async function prepareImage(buffer, { mode, cols, rows, scroll = 0, cellW, cellH, scaled = null }) {
|
|
47
|
+
const ch = cellH || DEFAULT_CELL_H;
|
|
48
|
+
const viewW = Math.max(1, Math.round(cols * (cellW || DEFAULT_CELL_W)));
|
|
49
|
+
const viewH = Math.max(1, Math.round(rows * ch));
|
|
50
|
+
|
|
51
|
+
if (mode === 'fit') {
|
|
52
|
+
const out = await sharp(buffer)
|
|
53
|
+
.resize({ width: viewW, height: viewH, fit: 'inside', withoutEnlargement: false })
|
|
54
|
+
.png()
|
|
55
|
+
.toBuffer();
|
|
56
|
+
const meta = await sharp(out).metadata();
|
|
57
|
+
return { buffer: out, maxScroll: 0, scroll: 0, imageRows: Math.round((meta.height || viewH) / ch) };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// mode 'width': scale to full width (reuse `scaled` when scrolling), then
|
|
61
|
+
// extract a vertical window.
|
|
62
|
+
const page = scaled || (await scalePage(buffer, { cols, cellW }));
|
|
63
|
+
const scaledH = page.height || viewH;
|
|
64
|
+
|
|
65
|
+
const maxScrollPx = Math.max(0, scaledH - viewH);
|
|
66
|
+
const maxScroll = Math.ceil(maxScrollPx / ch);
|
|
67
|
+
const clamped = Math.max(0, Math.min(scroll, maxScroll));
|
|
68
|
+
const top = Math.min(maxScrollPx, clamped * ch);
|
|
69
|
+
const cropH = Math.max(1, Math.min(viewH, scaledH - top));
|
|
70
|
+
|
|
71
|
+
const out = await sharp(page.buffer)
|
|
72
|
+
.extract({ left: 0, top: Math.round(top), width: page.width, height: Math.round(cropH) })
|
|
73
|
+
.png()
|
|
74
|
+
.toBuffer();
|
|
75
|
+
return { buffer: out, maxScroll, scroll: clamped, imageRows: Math.round(cropH / ch) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---- Sliceable sixel page (smooth vertical scrolling) ----------------------
|
|
79
|
+
// Re-encoding the viewport on every scroll step is the real bottleneck (~90ms of
|
|
80
|
+
// chafa + ~50ms of sharp per frame). But sixel is laid out as a fixed palette
|
|
81
|
+
// defined UP FRONT followed by independent 6px-tall bands, each of which
|
|
82
|
+
// re-selects its own colours. So we encode the whole page to sixel ONCE, then
|
|
83
|
+
// window the pre-encoded bands per frame — a string slice, not a re-encode.
|
|
84
|
+
// (Verified against chafa 1.14: every palette entry precedes the first band and
|
|
85
|
+
// every band starts with a colour select, so any band range stands alone.)
|
|
86
|
+
const BAND_PX = 6;
|
|
87
|
+
|
|
88
|
+
export function parseSixelPage(raw) {
|
|
89
|
+
const dcsStart = raw.indexOf('\x1bP');
|
|
90
|
+
if (dcsStart < 0) throw new Error('no sixel DCS found in encoder output');
|
|
91
|
+
const stEnd = raw.indexOf('\x1b\\', dcsStart);
|
|
92
|
+
const payload = raw.slice(dcsStart, stEnd < 0 ? undefined : stEnd);
|
|
93
|
+
|
|
94
|
+
const qi = payload.indexOf('q'); // the DCS intro (ESC P <params> q) ends at 'q'
|
|
95
|
+
const intro = qi >= 0 ? payload.slice(0, qi + 1) : '\x1bPq';
|
|
96
|
+
let rest = payload.slice(intro.length);
|
|
97
|
+
|
|
98
|
+
const rasterM = rest.match(/^"(\d+);(\d+);(\d+);(\d+)/);
|
|
99
|
+
const raster = rasterM
|
|
100
|
+
? { pan: rasterM[1], pad: rasterM[2], ph: rasterM[3] }
|
|
101
|
+
: { pan: '1', pad: '1', ph: '0' };
|
|
102
|
+
if (rasterM) rest = rest.slice(rasterM[0].length);
|
|
103
|
+
|
|
104
|
+
// The palette is defined entirely up front; the bands follow, separated by '-'.
|
|
105
|
+
const palette = rest.match(/^((?:#\d+;2;\d+;\d+;\d+)*)/)?.[1] ?? '';
|
|
106
|
+
const rawBands = rest.slice(palette.length).split('-');
|
|
107
|
+
while (rawBands.length && rawBands[rawBands.length - 1] === '') rawBands.pop();
|
|
108
|
+
|
|
109
|
+
// chafa omits a band's leading colour select when it equals the previous
|
|
110
|
+
// band's last colour (the register carries across '-'). For a band to be the
|
|
111
|
+
// TOP of a sliced window it must re-select that colour itself, so prepend the
|
|
112
|
+
// carried register wherever it's missing — making every band self-contained.
|
|
113
|
+
let carry = '0'; // sixel's default colour register
|
|
114
|
+
const bands = rawBands.map((b) => {
|
|
115
|
+
const fixed = b.startsWith('#') ? b : `#${carry}${b}`;
|
|
116
|
+
const sels = fixed.match(/#(\d+)/g);
|
|
117
|
+
if (sels) carry = sels[sels.length - 1].slice(1);
|
|
118
|
+
return fixed;
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
return { intro, raster, palette, bands, height: bands.length * BAND_PX };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Encode a full page to a parsed, sliceable sixel (one chafa call per page).
|
|
125
|
+
export async function encodeSixelPage(buffer) {
|
|
126
|
+
const raw = (await encodePixels(buffer, { format: 'sixel' })).toString('latin1');
|
|
127
|
+
return parseSixelPage(raw);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Compose a complete sixel image from a band window (sub-millisecond). Pv is
|
|
131
|
+
// rewritten to the window height; the palette/intro are reused verbatim.
|
|
132
|
+
// Returns { sixel: Buffer, startBand, bands } (startBand clamped to range).
|
|
133
|
+
export function sliceSixelPage(page, { startBand, numBands }) {
|
|
134
|
+
const total = page.bands.length;
|
|
135
|
+
const k = Math.max(1, Math.min(numBands, total));
|
|
136
|
+
const start = Math.max(0, Math.min(startBand, total - k));
|
|
137
|
+
const body = page.bands.slice(start, start + k).join('-');
|
|
138
|
+
const r = page.raster;
|
|
139
|
+
const str = `${page.intro}"${r.pan};${r.pad};${r.ph};${k * BAND_PX}${page.palette}${body}\x1b\\`;
|
|
140
|
+
return { sixel: Buffer.from(str, 'latin1'), startBand: start, bands: total };
|
|
141
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { getSource } from './sources/index.js';
|
|
2
|
+
import { setProgress } from './state/store.js';
|
|
3
|
+
import { chapterLabel } from './domain/shape.js';
|
|
4
|
+
import { encodePixels, prepareImage, scalePage, encodeSixelPage, sliceSixelPage } from './render/sixel.js';
|
|
5
|
+
import { logger } from './lib/logger.js';
|
|
6
|
+
|
|
7
|
+
const ESC = '\x1b';
|
|
8
|
+
|
|
9
|
+
// Synchronized-update markers (DEC private mode 2026). Wrapping a frame makes a
|
|
10
|
+
// supporting terminal present it atomically — so the strip-scroll's "scroll the
|
|
11
|
+
// region, then repaint the freed strip" can't flash a blank strip at the leading
|
|
12
|
+
// edge. Ignored (harmless) on terminals that don't implement it.
|
|
13
|
+
const SYNC_BEGIN = Buffer.from(`${ESC}[?2026h`, 'latin1');
|
|
14
|
+
const SYNC_END = Buffer.from(`${ESC}[?2026l`, 'latin1');
|
|
15
|
+
|
|
16
|
+
// A self-contained, raw-mode page reader that renders pages as sixel/kitty
|
|
17
|
+
// pixels. It fully owns the terminal (Ink is unmounted before this runs), so
|
|
18
|
+
// there's no cell layout to fight. Returns the route Ink should resume at.
|
|
19
|
+
export async function runViewer({ sourceId, manga, chapters, chapterIndex, startPage = 0, caps = {} }) {
|
|
20
|
+
const source = getSource(sourceId);
|
|
21
|
+
const { stdin, stdout } = process;
|
|
22
|
+
const format = caps.kitty ? 'kitty' : 'sixel';
|
|
23
|
+
|
|
24
|
+
let ci = chapterIndex;
|
|
25
|
+
let pi = startPage;
|
|
26
|
+
let scroll = 0; // current vertical pan (cells)
|
|
27
|
+
let fitWidth = true; // full-width + vertical pan (max resolution); `f` toggles
|
|
28
|
+
let pages = null;
|
|
29
|
+
let maxScroll = 0;
|
|
30
|
+
|
|
31
|
+
// Cell/band geometry for the optional strip-scroll. Sixel bands are 6px tall;
|
|
32
|
+
// the terminal scrolls by whole cells. A "slot" = LCM(6, cellH)px is the
|
|
33
|
+
// smallest shift that's whole in BOTH grids — scrolling by slots lets us move
|
|
34
|
+
// the on-screen pixels with the terminal and repaint only the newly-exposed
|
|
35
|
+
// strip (seam-free), instead of re-sending the entire viewport every step.
|
|
36
|
+
// Opt-in (KOMADO_SCROLL_DELTA=1) because it relies on the terminal scrolling
|
|
37
|
+
// sixel graphics with the text (true on xterm; not universal).
|
|
38
|
+
const cellH = caps.cellH || 20;
|
|
39
|
+
const gcd = (a, b) => (b ? gcd(b, a % b) : a);
|
|
40
|
+
const slotBands = cellH / gcd(6, cellH);
|
|
41
|
+
const slotCells = 6 / gcd(6, cellH);
|
|
42
|
+
const deltaScroll = format === 'sixel' && process.env.KOMADO_SCROLL_DELTA === '1';
|
|
43
|
+
const scrollStep = deltaScroll ? slotCells : 2;
|
|
44
|
+
let shownTop = null; // top band currently on screen (delta baseline); null ⇒ unknown
|
|
45
|
+
let shownSig = null; // page + geometry the on-screen frame was drawn for
|
|
46
|
+
|
|
47
|
+
// Per-page caches. draw() runs on every keypress, but the page bytes and the
|
|
48
|
+
// full-width scale are constant within a page — re-doing them per scroll step
|
|
49
|
+
// (a network round-trip per step for remote sources) is what made scrolling
|
|
50
|
+
// crawl. Cache both, keyed by page (and width for the scale).
|
|
51
|
+
let rawKey = null;
|
|
52
|
+
let rawBuf = null;
|
|
53
|
+
let scaledKey = null;
|
|
54
|
+
let scaledBuf = null;
|
|
55
|
+
let sixelKey = null;
|
|
56
|
+
let sixelPg = null;
|
|
57
|
+
|
|
58
|
+
// Render scheduler state: coalesce bursts of keypresses into the fewest draws
|
|
59
|
+
// instead of dropping input mid-draw. `inputSeq` lets a finishing draw detect
|
|
60
|
+
// whether the user moved on while it was rendering.
|
|
61
|
+
let inputSeq = 0;
|
|
62
|
+
let drawing = false;
|
|
63
|
+
let pending = false;
|
|
64
|
+
let pendingFullClear = false;
|
|
65
|
+
|
|
66
|
+
const size = () => ({
|
|
67
|
+
cols: Math.max(20, stdout.columns || 80),
|
|
68
|
+
rows: Math.max(6, stdout.rows || 24),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
async function ensurePages() {
|
|
72
|
+
if (pages) return;
|
|
73
|
+
pages = await source.getPages(chapters[ci].id);
|
|
74
|
+
pi = Math.max(0, Math.min(pi, pages.length - 1));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The page bytes, fetched once per page (not per scroll step).
|
|
78
|
+
async function pageBuffer() {
|
|
79
|
+
const key = `${ci}:${pi}`;
|
|
80
|
+
if (rawKey === key && rawBuf) return rawBuf;
|
|
81
|
+
rawBuf = await source.loadPageBuffer(pages[pi]);
|
|
82
|
+
rawKey = key;
|
|
83
|
+
return rawBuf;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The page scaled to full viewport width, reused across vertical scrolling.
|
|
87
|
+
async function scaledPage(cols) {
|
|
88
|
+
const key = `${ci}:${pi}:${cols}:${caps.cellW || ''}`;
|
|
89
|
+
if (scaledKey === key && scaledBuf) return scaledBuf;
|
|
90
|
+
scaledBuf = await scalePage(await pageBuffer(), { cols, cellW: caps.cellW });
|
|
91
|
+
scaledKey = key;
|
|
92
|
+
return scaledBuf;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// The page encoded to a sliceable sixel ONCE (palette + 6px bands), so every
|
|
96
|
+
// scroll step is a band-window slice instead of a fresh chafa+sharp encode.
|
|
97
|
+
async function sixelPageCached(cols) {
|
|
98
|
+
const key = `${ci}:${pi}:${cols}:${caps.cellW || ''}`;
|
|
99
|
+
if (sixelKey === key && sixelPg) return sixelPg;
|
|
100
|
+
const scaled = await scaledPage(cols);
|
|
101
|
+
sixelPg = await encodeSixelPage(scaled.buffer);
|
|
102
|
+
sixelKey = key;
|
|
103
|
+
return sixelPg;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function statusBar() {
|
|
107
|
+
const { cols } = size();
|
|
108
|
+
const left = `${manga.title} · ${chapterLabel(chapters[ci])} · ${pi + 1}/${pages ? pages.length : '?'}${fitWidth ? '' : ' · fit'}`;
|
|
109
|
+
const right = '←→ page · ↑↓ pan · N/P ch · f fit · q back';
|
|
110
|
+
const gap = Math.max(1, cols - left.length - right.length - 2);
|
|
111
|
+
return ` ${left}${' '.repeat(gap)}${right} `.slice(0, cols);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function draw({ fullClear = false } = {}) {
|
|
115
|
+
const seq = inputSeq;
|
|
116
|
+
const { cols, rows } = size();
|
|
117
|
+
const imgRows = rows - 1; // reserve the bottom row for the status bar
|
|
118
|
+
try {
|
|
119
|
+
await ensurePages();
|
|
120
|
+
if (!pages.length) throw new Error('This chapter has no hosted pages.');
|
|
121
|
+
|
|
122
|
+
// Build the bytes for this frame (null ⇒ nothing changed, skip the write).
|
|
123
|
+
// Default sixel path windows the pre-encoded page bands; with strip-scroll
|
|
124
|
+
// opted in, a slot-sized move scrolls the terminal and repaints only the
|
|
125
|
+
// exposed strip. 'fit'/kitty fall back to a per-frame encode.
|
|
126
|
+
let frame = null;
|
|
127
|
+
let imageRows = imgRows;
|
|
128
|
+
let mScroll = 0;
|
|
129
|
+
let sScroll = scroll;
|
|
130
|
+
|
|
131
|
+
if (fitWidth && format === 'sixel') {
|
|
132
|
+
const page = await sixelPageCached(cols);
|
|
133
|
+
const viewBands = Math.max(1, Math.floor((imgRows * cellH) / 6));
|
|
134
|
+
const maxStart = Math.max(0, page.bands.length - viewBands);
|
|
135
|
+
let topBand = Math.round((scroll * cellH) / 6);
|
|
136
|
+
if (deltaScroll) topBand = Math.round(topBand / slotBands) * slotBands;
|
|
137
|
+
topBand = Math.max(0, Math.min(topBand, maxStart));
|
|
138
|
+
|
|
139
|
+
imageRows = Math.round((viewBands * 6) / cellH);
|
|
140
|
+
mScroll = (maxStart * 6) / cellH;
|
|
141
|
+
sScroll = (topBand * 6) / cellH;
|
|
142
|
+
|
|
143
|
+
const geomSig = `${ci}:${pi}:${cols}:${imgRows}`;
|
|
144
|
+
const reuse = !fullClear && shownTop !== null && shownSig === geomSig;
|
|
145
|
+
const delta = reuse ? topBand - shownTop : 0;
|
|
146
|
+
|
|
147
|
+
if (reuse && delta === 0) {
|
|
148
|
+
frame = null; // identical frame already on screen
|
|
149
|
+
} else if (deltaScroll && reuse && delta !== 0
|
|
150
|
+
&& Math.abs(delta) % slotBands === 0 && Math.abs(delta) < viewBands) {
|
|
151
|
+
frame = buildDeltaFrame(page, { from: shownTop, to: topBand, viewBands, imgRows, rows });
|
|
152
|
+
} else {
|
|
153
|
+
const win = sliceSixelPage(page, { startBand: topBand, numBands: viewBands }).sixel;
|
|
154
|
+
frame = composeFull(win, { fullClear, imageRows, imgRows, rows });
|
|
155
|
+
}
|
|
156
|
+
shownTop = topBand;
|
|
157
|
+
shownSig = geomSig;
|
|
158
|
+
} else {
|
|
159
|
+
shownTop = null; // delta baseline doesn't apply to this render path
|
|
160
|
+
let prepared;
|
|
161
|
+
if (fitWidth) {
|
|
162
|
+
const page = await scaledPage(cols);
|
|
163
|
+
prepared = await prepareImage(null, {
|
|
164
|
+
mode: 'width', cols, rows: imgRows, scroll,
|
|
165
|
+
cellW: caps.cellW, cellH: caps.cellH, scaled: page,
|
|
166
|
+
});
|
|
167
|
+
} else {
|
|
168
|
+
prepared = await prepareImage(await pageBuffer(), {
|
|
169
|
+
mode: 'fit', cols, rows: imgRows, cellW: caps.cellW, cellH: caps.cellH,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
imageRows = prepared.imageRows; mScroll = prepared.maxScroll; sScroll = prepared.scroll;
|
|
173
|
+
const sig = `f:${ci}:${pi}:${sScroll}:${cols}:${imgRows}`;
|
|
174
|
+
if (fullClear || sig !== shownSig) {
|
|
175
|
+
const buf = await encodePixels(prepared.buffer, { format });
|
|
176
|
+
// 'fit' images are letterboxed (top-left, smaller than the viewport), so
|
|
177
|
+
// clear first or the previous full-width view shows through the margins.
|
|
178
|
+
frame = composeFull(buf, { fullClear: fullClear || !fitWidth, imageRows, imgRows, rows });
|
|
179
|
+
shownSig = sig;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Adopt the clamped scroll only if the user hasn't moved since this draw
|
|
184
|
+
// started; otherwise the newer keypress + its coalesced redraw owns it (a
|
|
185
|
+
// blind write-back here would undo input that arrived mid-render).
|
|
186
|
+
if (seq === inputSeq) {
|
|
187
|
+
maxScroll = mScroll;
|
|
188
|
+
scroll = Math.max(0, Math.min(sScroll, mScroll));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (frame) stdout.write(Buffer.concat([SYNC_BEGIN, frame, SYNC_END]));
|
|
192
|
+
|
|
193
|
+
setProgress(manga.key, {
|
|
194
|
+
source: sourceId,
|
|
195
|
+
mangaId: manga.id,
|
|
196
|
+
mangaTitle: manga.title,
|
|
197
|
+
chapterId: chapters[ci].id,
|
|
198
|
+
chapterNumber: chapters[ci].number,
|
|
199
|
+
page: pi,
|
|
200
|
+
});
|
|
201
|
+
// Last page reached → push a read-marker to MangaDex (self-guarded/deduped).
|
|
202
|
+
if (pi === pages.length - 1 && source.syncChapterRead) {
|
|
203
|
+
source.syncChapterRead(manga.id, chapters[ci].id);
|
|
204
|
+
}
|
|
205
|
+
} catch (err) {
|
|
206
|
+
logger.warn('viewer draw failed', err);
|
|
207
|
+
stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
|
|
208
|
+
stdout.write(`Error: ${err.message}\r\n\r\nN/P chapter · q back\r\n`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Whole-viewport frame: home + overwrite (no full ESC[2J each step — that
|
|
213
|
+
// clear-to-blank is what made scrolling blink), erase only the rows a shorter
|
|
214
|
+
// image leaves below, then the status bar. One atomic write avoids partial
|
|
215
|
+
// frames and extra syscalls.
|
|
216
|
+
function composeFull(sixelBuf, { fullClear, imageRows, imgRows, rows }) {
|
|
217
|
+
const prefix = fullClear ? `${ESC}[2J${ESC}[H` : `${ESC}[H`;
|
|
218
|
+
let suffix = imageRows < imgRows ? `${ESC}[${imageRows + 1};1H${ESC}[0J` : '';
|
|
219
|
+
suffix += `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
|
|
220
|
+
return Buffer.concat([Buffer.from(prefix, 'latin1'), sixelBuf, Buffer.from(suffix, 'latin1')]);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Strip-scroll frame (opt-in): scroll the image area with the terminal and
|
|
224
|
+
// repaint only the slot of sixel that scrolled into view — far less data than
|
|
225
|
+
// re-sending the whole viewport. Valid only for slot-sized shifts (whole cells
|
|
226
|
+
// AND whole bands), so the moved pixels stay band-aligned and there's no seam.
|
|
227
|
+
// Uses LF/RI inside a DECSTBM region (the most widely supported scroll path).
|
|
228
|
+
function buildDeltaFrame(page, { from, to, viewBands, imgRows, rows }) {
|
|
229
|
+
const delta = to - from; // bands, a non-zero multiple of slotBands
|
|
230
|
+
const dc = Math.round((Math.abs(delta) * 6) / cellH); // whole cells scrolled
|
|
231
|
+
const region = `${ESC}[1;${imgRows}r`;
|
|
232
|
+
const reset = `${ESC}[r`;
|
|
233
|
+
const status = `${ESC}[${rows};1H${ESC}[7m${statusBar()}${ESC}[0m`;
|
|
234
|
+
let head; let strip;
|
|
235
|
+
if (delta > 0) {
|
|
236
|
+
// content up → repaint the freed strip at the bottom
|
|
237
|
+
head = `${region}${ESC}[${imgRows};1H${'\n'.repeat(dc)}${reset}${ESC}[${imgRows - dc + 1};1H`;
|
|
238
|
+
strip = sliceSixelPage(page, { startBand: from + viewBands, numBands: delta }).sixel;
|
|
239
|
+
} else {
|
|
240
|
+
// content down → repaint the freed strip at the top
|
|
241
|
+
head = `${region}${ESC}[1;1H${`${ESC}M`.repeat(dc)}${reset}${ESC}[1;1H`;
|
|
242
|
+
strip = sliceSixelPage(page, { startBand: to, numBands: -delta }).sixel;
|
|
243
|
+
}
|
|
244
|
+
return Buffer.concat([Buffer.from(head, 'latin1'), strip, Buffer.from(status, 'latin1')]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Coalescing scheduler: while a draw runs, extra requests collapse into a
|
|
248
|
+
// single follow-up at the latest state — input is never dropped and draws
|
|
249
|
+
// never stack up behind a slow encode.
|
|
250
|
+
function schedule({ fullClear = false } = {}) {
|
|
251
|
+
if (fullClear) pendingFullClear = true;
|
|
252
|
+
if (drawing) { pending = true; return; }
|
|
253
|
+
drawing = true;
|
|
254
|
+
(async () => {
|
|
255
|
+
try {
|
|
256
|
+
do {
|
|
257
|
+
pending = false;
|
|
258
|
+
const fc = pendingFullClear;
|
|
259
|
+
pendingFullClear = false;
|
|
260
|
+
await draw({ fullClear: fc });
|
|
261
|
+
} while (pending);
|
|
262
|
+
} finally {
|
|
263
|
+
drawing = false;
|
|
264
|
+
}
|
|
265
|
+
})();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function changeChapter(delta) {
|
|
269
|
+
const next = ci + delta;
|
|
270
|
+
if (next < 0 || next >= chapters.length) return false;
|
|
271
|
+
ci = next;
|
|
272
|
+
pi = 0;
|
|
273
|
+
scroll = 0;
|
|
274
|
+
shownTop = null;
|
|
275
|
+
pages = null;
|
|
276
|
+
return true;
|
|
277
|
+
}
|
|
278
|
+
function nextPage() {
|
|
279
|
+
if (pages && pi < pages.length - 1) { pi += 1; scroll = 0; shownTop = null; }
|
|
280
|
+
else changeChapter(1);
|
|
281
|
+
}
|
|
282
|
+
function prevPage() {
|
|
283
|
+
if (pi > 0) { pi -= 1; scroll = 0; shownTop = null; }
|
|
284
|
+
else changeChapter(-1);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const prevRaw = stdin.isRaw;
|
|
288
|
+
let onKey;
|
|
289
|
+
// Ink leaves stdin unref'd after unmount, so a bare `await`-for-keypress won't
|
|
290
|
+
// keep the process alive — it would exit the moment the first page is drawn.
|
|
291
|
+
// A ref'd timer holds the event loop open until we're done.
|
|
292
|
+
const keepAlive = setInterval(() => {}, 1 << 30);
|
|
293
|
+
// Re-render on terminal resize so the page tracks the window size. Full clear:
|
|
294
|
+
// a narrower/shorter window can leave stale pixels outside the new image.
|
|
295
|
+
const onResize = () => { inputSeq += 1; schedule({ fullClear: true }); };
|
|
296
|
+
try {
|
|
297
|
+
await draw({ fullClear: true });
|
|
298
|
+
stdout.on('resize', onResize);
|
|
299
|
+
|
|
300
|
+
await new Promise((resolve) => {
|
|
301
|
+
onKey = (data) => {
|
|
302
|
+
const k = data.toString('latin1');
|
|
303
|
+
const pageStep = Math.max(1, size().rows - 2);
|
|
304
|
+
|
|
305
|
+
if (k === 'q' || k === ESC) { resolve(); return; }
|
|
306
|
+
if (k === 'n' || k === ' ') {
|
|
307
|
+
if (fitWidth && scroll < maxScroll) scroll = Math.min(maxScroll, scroll + pageStep);
|
|
308
|
+
else nextPage();
|
|
309
|
+
} else if (k === 'p') {
|
|
310
|
+
if (fitWidth && scroll > 0) scroll = Math.max(0, scroll - pageStep);
|
|
311
|
+
else prevPage();
|
|
312
|
+
} else if (k === 'd' || k === `${ESC}[C`) {
|
|
313
|
+
nextPage(); // → / d : next page
|
|
314
|
+
} else if (k === 'a' || k === `${ESC}[D`) {
|
|
315
|
+
prevPage(); // ← / a : previous page
|
|
316
|
+
} else if (k === 'j' || k === `${ESC}[B`) {
|
|
317
|
+
scroll = Math.min(maxScroll, scroll + scrollStep);
|
|
318
|
+
} else if (k === 'k' || k === `${ESC}[A`) {
|
|
319
|
+
scroll = Math.max(0, scroll - scrollStep);
|
|
320
|
+
} else if (k === 'N') {
|
|
321
|
+
changeChapter(1);
|
|
322
|
+
} else if (k === 'P') {
|
|
323
|
+
changeChapter(-1);
|
|
324
|
+
} else if (k === 'f') {
|
|
325
|
+
fitWidth = !fitWidth;
|
|
326
|
+
scroll = 0;
|
|
327
|
+
shownTop = null; shownSig = null; // render path changes → fresh baseline
|
|
328
|
+
} else if (k === 'g') {
|
|
329
|
+
scroll = 0; // jump to top
|
|
330
|
+
} else if (k === 'G') {
|
|
331
|
+
scroll = maxScroll; // jump to bottom
|
|
332
|
+
} else {
|
|
333
|
+
return; // ignore other keys without redrawing
|
|
334
|
+
}
|
|
335
|
+
// Update state synchronously, then coalesce the redraw — rapid repeats
|
|
336
|
+
// collapse into the fewest draws instead of being dropped mid-draw.
|
|
337
|
+
inputSeq += 1;
|
|
338
|
+
schedule();
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
// Enter raw mode *after* the first draw — Ink's unmount restores cooked
|
|
342
|
+
// mode on a deferred tick, which would otherwise leave stdin line-buffered
|
|
343
|
+
// (so single keypresses never arrive).
|
|
344
|
+
stdin.removeAllListeners('data');
|
|
345
|
+
try { stdin.setRawMode(true); } catch { /* ignore */ }
|
|
346
|
+
stdin.resume();
|
|
347
|
+
stdin.ref?.();
|
|
348
|
+
stdin.on('data', onKey);
|
|
349
|
+
});
|
|
350
|
+
} finally {
|
|
351
|
+
clearInterval(keepAlive);
|
|
352
|
+
stdout.removeListener('resize', onResize);
|
|
353
|
+
if (onKey) stdin.removeListener('data', onKey);
|
|
354
|
+
try { stdin.setRawMode(prevRaw); } catch { /* ignore */ }
|
|
355
|
+
stdout.write(`${ESC}[2J${ESC}[H${ESC}[0m`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return { name: 'manga', params: { sourceId, manga } };
|
|
359
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as mangadex from './mangadex/index.js';
|
|
2
|
+
import * as local from './local/index.js';
|
|
3
|
+
|
|
4
|
+
// Source registry. Every source implements the same interface:
|
|
5
|
+
// search, getManga, listChapters, getPages, loadPageBuffer
|
|
6
|
+
// so the hooks/UI never branch on where a manga comes from.
|
|
7
|
+
const sources = { mangadex, local };
|
|
8
|
+
|
|
9
|
+
export function getSource(sourceId) {
|
|
10
|
+
const source = sources[sourceId];
|
|
11
|
+
if (!source) throw new Error(`Unknown source: ${sourceId}`);
|
|
12
|
+
return source;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SOURCES = sources;
|
|
16
|
+
export const REMOTE_SOURCES = Object.values(sources).filter((s) => s.remote);
|
|
17
|
+
export const LOCAL_SOURCES = Object.values(sources).filter((s) => !s.remote);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import AdmZip from 'adm-zip';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { naturalSort } from '../../lib/natsort.js';
|
|
4
|
+
|
|
5
|
+
const IMAGE_RE = /\.(jpe?g|png|gif|webp|bmp|avif)$/i;
|
|
6
|
+
|
|
7
|
+
export const isArchive = (name) => /\.(cbz|zip|cbr|rar)$/i.test(name);
|
|
8
|
+
export const isRar = (name) => /\.(cbr|rar)$/i.test(name);
|
|
9
|
+
|
|
10
|
+
// ---- ZIP / CBZ (adm-zip parses the whole file on construct → tiny LRU) ----
|
|
11
|
+
const zipCache = new Map();
|
|
12
|
+
function openZip(filePath) {
|
|
13
|
+
let zip = zipCache.get(filePath);
|
|
14
|
+
if (!zip) {
|
|
15
|
+
zip = new AdmZip(filePath);
|
|
16
|
+
zipCache.set(filePath, zip);
|
|
17
|
+
if (zipCache.size > 8) zipCache.delete(zipCache.keys().next().value);
|
|
18
|
+
}
|
|
19
|
+
return zip;
|
|
20
|
+
}
|
|
21
|
+
function listZipImages(filePath) {
|
|
22
|
+
return naturalSort(
|
|
23
|
+
openZip(filePath)
|
|
24
|
+
.getEntries()
|
|
25
|
+
.filter((e) => !e.isDirectory && IMAGE_RE.test(e.entryName))
|
|
26
|
+
.map((e) => e.entryName),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
function readZipEntry(filePath, entryName) {
|
|
30
|
+
const entry = openZip(filePath).getEntry(entryName);
|
|
31
|
+
if (!entry) throw new Error(`Entry not found in archive: ${entryName}`);
|
|
32
|
+
return entry.getData();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- RAR / CBR (node-unrar-js — WASM, loaded lazily on first use) ----
|
|
36
|
+
let unrarPromise = null;
|
|
37
|
+
const getUnrar = () => (unrarPromise ??= import('node-unrar-js'));
|
|
38
|
+
|
|
39
|
+
const rarCache = new Map();
|
|
40
|
+
async function openRar(filePath) {
|
|
41
|
+
let extractor = rarCache.get(filePath);
|
|
42
|
+
if (!extractor) {
|
|
43
|
+
const { createExtractorFromData } = await getUnrar();
|
|
44
|
+
const buf = await readFile(filePath);
|
|
45
|
+
extractor = await createExtractorFromData({
|
|
46
|
+
data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength),
|
|
47
|
+
});
|
|
48
|
+
rarCache.set(filePath, extractor);
|
|
49
|
+
if (rarCache.size > 4) rarCache.delete(rarCache.keys().next().value);
|
|
50
|
+
}
|
|
51
|
+
return extractor;
|
|
52
|
+
}
|
|
53
|
+
async function listRarImages(filePath) {
|
|
54
|
+
const extractor = await openRar(filePath);
|
|
55
|
+
const names = [];
|
|
56
|
+
for (const header of extractor.getFileList().fileHeaders) {
|
|
57
|
+
if (!header.flags.directory && IMAGE_RE.test(header.name)) names.push(header.name);
|
|
58
|
+
}
|
|
59
|
+
return naturalSort(names);
|
|
60
|
+
}
|
|
61
|
+
async function readRarEntry(filePath, entryName) {
|
|
62
|
+
const extractor = await openRar(filePath);
|
|
63
|
+
const [file] = [...extractor.extract({ files: [entryName] }).files];
|
|
64
|
+
if (!file?.extraction) throw new Error(`Failed to extract from RAR: ${entryName}`);
|
|
65
|
+
return Buffer.from(file.extraction);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---- Unified async API (callers don't care about the container format) ----
|
|
69
|
+
export async function listArchiveImages(filePath) {
|
|
70
|
+
return isRar(filePath) ? listRarImages(filePath) : listZipImages(filePath);
|
|
71
|
+
}
|
|
72
|
+
export async function readArchiveEntry(filePath, entryName) {
|
|
73
|
+
return isRar(filePath) ? readRarEntry(filePath, entryName) : readZipEntry(filePath, entryName);
|
|
74
|
+
}
|