diff --git a/README.md b/README.md index c608f88e..ea5c08e1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,12 @@ Type what you're looking for and press Enter. Results stream in from every sourc torlink's browse view: the sidebar, the search bar, and merged results from every source

+Press `i` for a closer look at whatever's highlighted — poster, rating, cast and the rest, right beside the list. `→` opens it up to read and scroll through, `←` sends you back to browsing. + +

+ torlink's info pane opened up to read: poster art beside the full synopsis, rating, cast and crew for the highlighted result +

+ ## Your downloads Active downloads sit up top with their progress, speed, and time left; when one finishes it drops into Recently downloaded just below, so the list stays tidy. Everything's still there when you come back, and anything interrupted picks up where it left off. diff --git a/package-lock.json b/package-lock.json index dd13f04f..ffaad950 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.0.5", + "jpeg-js": "^0.4.4", "parse-torrent": "^11.0.21", "react": "^19.2.7", "uint8-util": "2.2.6", @@ -3132,6 +3133,11 @@ "node": ">=10" } }, + "node_modules/jpeg-js": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", + "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==" + }, "node_modules/junk": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/junk/-/junk-4.0.1.tgz", diff --git a/package.json b/package.json index 48980cf4..e76e14be 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "create-torrent": "^6.1.2", "env-paths": "^4.0.0", "ink": "^7.0.5", + "jpeg-js": "^0.4.4", "parse-torrent": "^11.0.21", "react": "^19.2.7", "uint8-util": "2.2.6", diff --git a/preview/browse.svg b/preview/browse.svg index 951a1c82..0e7b8b08 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -117,9 +117,9 @@ Sort / Search - tab - Switch - ? - Keys + f + Filter + tab + Switch … \ No newline at end of file diff --git a/preview/downloads.svg b/preview/downloads.svg index 55d62483..9601b3b4 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -48,8 +48,8 @@ Dune: Part Two (2024) [2160p BluRay] - 7.82 GB - YTS + 7.82 GB + YTS Movies diff --git a/preview/info.svg b/preview/info.svg new file mode 100644 index 00000000..daf0de2f --- /dev/null +++ b/preview/info.svg @@ -0,0 +1,515 @@ + + + + + torlink + + 𐓏 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── + + All + ╭─ + Search + ──────────────────────────────────────────────────────────────────────────────────────────╮ + Games + + + Search or paste a magnet link… + + Movies + ╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ + TV + Anime + ╭─ + Latest (5) + ─────────────────────────────────────╮ + ╭─ + Info + ───────────────────────────────────────╮ + + newest across all sources + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Oppenheimer + + Downloads + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 2023 · 8.3 · 180 min + + Seeding + + # + Name + Size + Seed:Lch + Src + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Biography, Drama, History + + + + 1 + Oppenheimer (202… + 1.96 GB + 1240:88 + YTS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Dir Christopher Nolan + + + 2 + Dune: Part Two (… + 7.82 GB + 910:41 + YTS + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Cast Cillian Murphy, Emily + + + 3 + Breaking Bad S05… + 1.49 GB + 540:31 + EZTV + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Blunt, Matt Damon + + + 4 + [Erai-raws] Juju… + 1.21 GB + 320:12 + NYAA + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The story of J. Robert + + + 5 + Frieren - 28 [10… + 1.30 GB + - + SUB + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Oppenheimer's role in the + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + development of the atomic + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + bomb during World War II. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╰──────────────────────────────────────────────────╯ + ╰──────────────────────────────────────────────╯ + ↑↓ + Scroll + + Back + tab + Switch + ? + Keys + + \ No newline at end of file diff --git a/scripts/ansi-to-svg.ts b/scripts/ansi-to-svg.ts index 0a2e6cf6..f0961e0b 100644 --- a/scripts/ansi-to-svg.ts +++ b/scripts/ansi-to-svg.ts @@ -229,10 +229,21 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { const top = baseline - FONT_SIZE; const half = LINE_H / 2; const dim = st.dim ? ` fill-opacity="${DIM_OPACITY}"` : ""; + // Same convention as the general path below: a half-block glyph only paints one half of + // its cell, so the other half has to come from the background colour, exactly as a real + // terminal composites it (bg fills the whole cell, the glyph's fg sits on top of half of + // it). Without this a run with a distinct background — a two-tone poster row, not a + // single-colour progress-bar fill — silently loses whichever half the glyph doesn't cover. + const boxFill = st.inverse ? fg : st.bg; const bcells = Array.from(text); for (let k = 0; k < n; k++) { const ch = bcells[k]!; const cellLeft = PAD + (col + k) * CHAR_W; + if (boxFill) { + out.push( + ` `, + ); + } let rx = cellLeft; let ry = top; let rw = CHAR_W; diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index 39aa8d1d..0e627142 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -5,22 +5,37 @@ import React from "react"; import { render } from "ink-testing-library"; import { Box, Text } from "ink"; import { StoreContext, type Store } from "../src/ui/store"; -import { COLOR, ICON, SOURCE_STYLE } from "../src/ui/theme"; +import { COLOR, ICON, RULE, SOURCE_STYLE, lerpHex } from "../src/ui/theme"; import { Logo } from "../src/ui/components/Logo"; import { Rule } from "../src/ui/components/Rule"; import { Footer } from "../src/ui/components/Footer"; import { Sidebar, RAIL_WIDTH } from "../src/ui/components/Sidebar"; import { SearchBar } from "../src/ui/components/SearchBar"; import { Panel } from "../src/ui/components/Panel"; +import { Poster } from "../src/ui/components/Poster"; import { Downloads } from "../src/ui/components/Downloads"; import { footerHints } from "../src/ui/keymap"; +import { planPaneLines } from "../src/ui/paneCard"; +import { + COLUMN_GAP, + MAX_TEXT_COLS, + PANE_GAP, + POSTER_H, + POSTER_W, + posterBudget, + previewLayout, + splitTextCols, +} from "../src/ui/previewLayout"; import { sourcesByGroup } from "../src/sources/registry"; import { cleanText, formatBytes, formatRelative } from "../src/util/format"; import { ansiToSvg, type AnsiToSvgOptions } from "./ansi-to-svg"; +import { fitCells } from "../src/meta/image"; import type { Config } from "../src/config/config"; import type { DownloadQueue } from "../src/download/queue"; import type { QueueItem, SeedItem } from "../src/download/types"; import type { HistoryItem } from "../src/download/history"; +import type { Meta } from "../src/meta/types"; +import type { PosterCells } from "../src/meta/image"; import type { TorrentResult } from "../src/sources/types"; const COLS = 80; @@ -98,6 +113,8 @@ function makeStore( setSeedFocus: noop, resultFocus: null, setResultFocus: noop, + previewOpen: false, + setPreviewOpen: noop, startDownload: noop, requestDownloadTo: noop, copyMagnet: noop, @@ -266,3 +283,198 @@ save( , { shimmer: true }, ); + +// previewLayout only gives the results list a pane once contentWidth reaches 73, and only draws +// poster art in it from 86 — the 80-column COLS every other scenario uses never gets there. This +// one runs wider, on purpose, so the info pane has something to show. +const META_COLS = 120; +const META_CONTENT_WIDTH = Math.max(24, META_COLS - RAIL_WIDTH - 3); +const META_RULE_WIDTH = Math.max(10, META_COLS - 2); +const META_PANEL_H = 18; +const metaInnerRows = Math.max(0, META_PANEL_H - 1); + +// Focused (`region: "preview"`, the state the → key puts the pane in), not the browsing state the +// list keeps by default: reading the pane side by side with a full-size poster is what focusing it +// is *for*. It is also the state most likely to actually show a plot — unfocused, this fixture's +// director plus a three-name cast alone can saturate the browsing tier's fixed text budget before +// the plot is ever reached, while focused the budget is infinite and the whole synopsis is built. +const metaPane = previewLayout(META_CONTENT_WIDTH, true, metaInnerRows); +if (metaPane === null) { + throw new Error(`preview: "info" scenario's ${META_COLS} columns are too narrow for the pane`); +} + +const META: Meta = { + imdbId: "tt15398776", + kind: "movie", + title: "Oppenheimer", + year: "2023", + rating: "8.3", + runtime: "180 min", + genres: ["Biography", "Drama", "History"], + director: ["Christopher Nolan"], + cast: ["Cillian Murphy", "Emily Blunt", "Matt Damon"], + plot: + "The story of J. Robert Oppenheimer's role in the development of the atomic bomb during World War II.", +}; + +// Panel's frame (border 2 + paddingX 2), same arithmetic MetaPane does for its own inner width. +const metaInner = Math.max(1, metaPane.pane - 4); +const metaBudget = posterBudget(metaPane.pane, metaInnerRows, true); +if (metaBudget === null) { + throw new Error(`preview: "info" scenario's panel is too short to budget poster rows`); +} + +// The pane's own natural art size — the same question MetaPane asks usePoster (and, under it, +// fitCells) once a real poster decodes. The preview has no JPEG to decode, so this mirrors the one +// rendition previewLayout.ts itself reasons the split from (POSTER_W / POSTER_H, imported rather +// than copied) rather than assuming the budget's cell grid is the picture's actual shape. +const metaArt = fitCells(POSTER_W, POSTER_H, metaBudget.cols, metaBudget.rows); + +const metaCardCols = splitTextCols(metaInner, metaArt.cols); +if (metaCardCols === null) { + throw new Error(`preview: "info" scenario's ${META_COLS} columns can't seat the card beside the poster`); +} +const metaTextWidth = Math.min(metaCardCols, MAX_TEXT_COLS); + +/** + * A gradient standing in for a decoded poster — the previews never touch the network, so there is + * no JPEG to decode. Built with the same lerpHex the shimmer sheen uses, staying inside the app's + * own palette: the bottom is COLOR.bright as-is, the top is COLOR.accent darkened toward RULE, so + * every colour this ramp touches is already exported from theme.ts. + */ +function posterMock(cols: number, rows: number): PosterCells { + const top = lerpHex(COLOR.accent, RULE, 0.75); + const bottom = COLOR.bright; + const lines = Array.from({ length: rows }, (_, row) => { + const t = rows <= 1 ? 0 : row / (rows - 1); + const fg = lerpHex(top, bottom, t); + const bg = lerpHex(top, bottom, Math.min(1, t + 1 / (rows * 2))); + return [{ fg, bg, n: cols }]; + }); + return { cols, rows, lines }; +} + +// The app's own card planner, flattened to one entry per terminal row exactly as MetaPane +// flattens it. The preview is a screenshot of the pane, so it has to be laid out by the thing +// that lays the pane out — a second copy of the card here would drift the moment either changes. +// Focused, the budget is infinite (MetaPane.tsx's own textBudget branch): the window that would +// normally cut it is the scroll offset, and this scenario is sized so nothing is off screen. +const metaTextRows = planPaneLines(META, metaTextWidth, Number.POSITIVE_INFINITY).flatMap((l) => + l.text.split("\n").map((text, i) => ({ key: `${l.key}:${i}`, text, tone: l.tone })), +); + +// The guarantee this scenario exists to show: the whole card fits beside the poster with nothing +// left to scroll to. If a future fixture change ever pushes it past the pane's height, that is the +// bug the earlier attempt at this task caught by hand — catch it here instead of shipping a +// screenshot with a hidden "↓ more". +const metaTotalRows = Math.max(metaArt.rows, metaTextRows.length); +if (metaTotalRows > metaInnerRows) { + throw new Error( + `preview: "info" scenario's card no longer fits without scrolling (${metaTotalRows} rows > ${metaInnerRows})`, + ); +} + +save( + "info", + makeStore({ + section: "all", + contentWidth: META_CONTENT_WIDTH, + listRows: 14, + cols: META_COLS, + rows: 24, + region: "preview", + }), + + + + + + {/* +5: SearchBar's own Panel (a label row + a 2-row box) plus the 1-row gap above this + Panel's own label row — everything this column holds above the results Panel, which a + fixed outer height has to leave room for. Too little and Yoga shrinks the Panel instead + of just cropping blank space, and a shrunk fixed-height Panel drops its own overflowing + content rather than showing a clean edge. */} + + + + {}} /> + + + newest across all sources + + + + # + Name + Size + Seed:Lch + Src + + {browseResults.map((r, i) => { + const here = i === 0; + const ss = SOURCE_STYLE[r.source]; + return ( + + + {here ? ICON.pointer : ""} + + + {i + 1} + + + + {cleanText(r.name)} + + + {showStats ? ( + <> + + {r.sizeBytes > 0 ? formatBytes(r.sizeBytes) : "-"} + + + 0 ? COLOR.good : undefined} dimColor={r.seeders === 0}> + {r.seeders || r.leechers ? `${r.seeders}:${r.leechers}` : "-"} + + + + ) : ( + + {formatRelative(r.added) || "-"} + + )} + + + {ss.tag} + + + + ); + })} + + + + + {/* Side by side, exactly as MetaPane's own `split` branch draws it: the art keeps its + natural width and the card runs the full height beside it, rather than the + budget's whole row allowance stretching a picture no decoder actually returned. */} + + + + + + {metaTextRows.map((l) => ( + + {l.text} + + ))} + + + + + + + +