diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4e56833 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 bairon.dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index c2da4ec..dfa2931 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ torlink, curated torrents straight from your terminal

-torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer. The files are yours, saved to your downloads folder. +torlink is a torrent finder that lives in your terminal, with zero setup and nothing to configure. One search checks a short, curated list of reputable sources at once, and whatever you pick downloads straight to your computer, never seeding back out. The files are yours, saved to your downloads folder. ## Get started @@ -22,16 +22,9 @@ npx torlink "" # open a .torrent file npm install -g torlink # install for good, then run: torlink ``` -## What it does - -- **One search, every source.** Type once and torlink checks them all together, showing each result's size and how many people are sharing it. -- **Curated, not a free-for-all.** A small, vetted set of sources, tagged so you always know where a result came from. -- **Downloads only, never seeds.** Files come to your computer and stop there. torlink never sends them back out. -- **Nothing to configure.** It opens straight to a search box, ready the moment it starts. - ## Finding something -Type what you're looking for and press Enter. torlink checks every source at once, and results show up as each one answers, so you're never stuck waiting on a slow one. Each result lists its size and how many people are sharing it, so you can see which one will come down fast. Arrow to the one you want, press `d`, and it saves to your downloads folder. Leave the box empty and press Enter to just see what's new across every source. +Type what you're looking for and press Enter. Results stream in from every source as they answer, tagged with size and how many people are sharing each one, so you can see what'll come down fast. Arrow to what you want and press `d` to save it.

torlink's browse view: the sidebar, the search bar, and merged results from every source @@ -39,7 +32,9 @@ Type what you're looking for and press Enter. torlink checks every source at onc ## Your downloads -Your active downloads and the stuff you've already grabbed share one pane. Whatever's still coming down sits up top with its progress, speed, and time left. When a file finishes it drops into Recently downloaded just below, so the list stays tidy instead of piling up. Anything there is one key from downloading again, and it's all still waiting when you come back, resuming anything that got interrupted. +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. + +Downloads run in the background while you keep searching, so you can queue up as many as you want. They save to your downloads folder, and the Downloads pane keeps tabs on each one.

torlink's Downloads pane: live progress on top, recently downloaded below @@ -56,7 +51,7 @@ A short, hand-picked list of trusted sources: | TV | EZTV, SolidTorrents, The Pirate Bay, 1337x | | Anime | Nyaa, SubsPlease | -Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. A source being down never stops a search, the others keep going and torlink tells you which one is offline. +Games are the only category that can run code, so they come from FitGirl alone, a repacker with a long, trusted track record; everything else is plain video and subtitles. If a source is down, the search carries on without it, and torlink tells you which one is offline. ## Local Development diff --git a/preview/browse.svg b/preview/browse.svg index 4d6fddc..b0dcee3 100644 --- a/preview/browse.svg +++ b/preview/browse.svg @@ -1,7 +1,7 @@ - - + + torlink 𐓏 @@ -47,7 +47,7 @@ Games ❯ - Search, or paste a magnet… + Search or paste a magnet link… Movies ╰───────────────────────────────────────────────────────────╯ diff --git a/preview/downloads.svg b/preview/downloads.svg index 92cb81e..972503e 100644 --- a/preview/downloads.svg +++ b/preview/downloads.svg @@ -1,7 +1,7 @@ - - + + torlink 𐓏 @@ -108,4 +108,46 @@ ? Keys + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/preview/splash.svg b/preview/splash.svg index 2ae442e..bd904ab 100644 --- a/preview/splash.svg +++ b/preview/splash.svg @@ -1,7 +1,7 @@ - - + + torlink 𐓏 @@ -46,8 +46,8 @@ ❯ - S - earch, or paste a magnet… + S + earch or paste a magnet link… ╰────────────────────────────────────────────────────────────╯ ↡ diff --git a/scripts/ansi-to-svg.ts b/scripts/ansi-to-svg.ts index bd8069d..0a2e6cf 100644 --- a/scripts/ansi-to-svg.ts +++ b/scripts/ansi-to-svg.ts @@ -1,8 +1,20 @@ +import { lerpHex } from "../src/ui/theme"; +import { + SHEEN_PEAK, + SHEEN_TICK_MS, + sheenCenter, + sheenIntensity, + sheenLoopTicks, + sheenPeriod, +} from "../src/ui/sheen"; + export interface AnsiToSvgOptions { cols: number; bg?: string; title?: string; maxWidth?: number; + /** Animate a sweeping highlight over progress-bar fills (rows with a β–‘ track). */ + shimmer?: boolean; } interface Style { @@ -26,7 +38,7 @@ const LINE_H = 22; const PAD = 32; const HEADER_H = 36; const RADIUS = 14; -const BG = "#0c0b14"; +const BG = "#0a0810"; const FG_DEFAULT = "#ddd8ea"; const DIM_OPACITY = 0.55; const FONT_STACK = @@ -144,7 +156,7 @@ function escapeXml(s: string): string { const fmt = (n: number): string => String(Math.round(n * 100) / 100); export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { - const { cols, bg = BG, title, maxWidth } = opts; + const { cols, bg = BG, title, maxWidth, shimmer = false } = opts; const lines = frame.replace(/\r/g, "").split("\n"); const isBlank = (l: string): boolean => l.replace(/\x1b\[[0-9;]*m/g, "").trim() === ""; @@ -162,7 +174,7 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { ); out.push(` `); out.push( - ` `, + ` `, ); if (title) { out.push( @@ -173,6 +185,9 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { ` `, ); + const barRuns: { row: number; col: number; n: number; fg: string }[] = []; + const trackLen = new Map(); + const state = { style: freshStyle() }; lines.forEach((line, row) => { const runs = parseLine(line, state, cols); @@ -195,6 +210,11 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { const x = PAD + col * CHAR_W; const w = n * CHAR_W; const fg = st.fg ?? FG_DEFAULT; + if (shimmer) { + if (/^β–ˆ+$/.test(text)) barRuns.push({ row, col, n, fg }); + const dim = (text.match(/β–‘/g) ?? []).length; + if (dim > 0) trackLen.set(row, (trackLen.get(row) ?? 0) + dim); + } if (/^[│┃]+$/.test(text)) { const barW = text[0] === "┃" ? 2.8 : 1.4; for (let k = 0; k < n; k++) { @@ -251,6 +271,57 @@ export function ansiToSvg(frame: string, opts: AnsiToSvgOptions): string { }); out.push(" "); + + if (shimmer && barRuns.length > 0) { + // Reproduce the live ProgressBar sheen 1:1: for each filled cell, animate + // its fill through the exact bell-tinted colors, snapping frame-to-frame + // (calcMode="discrete") like the terminal repaint, so it stays pixelated. + const byRow = new Map(); + for (const r of barRuns) { + if (!trackLen.has(r.row)) continue; + const arr = byRow.get(r.row) ?? []; + arr.push({ col: r.col, n: r.n, fg: r.fg }); + byRow.set(r.row, arr); + } + + for (const [row, parts] of byRow) { + parts.sort((a, b) => a.col - b.col); + // Merge contiguous β–ˆ runs into one region, expanded to per-cell colors. + const regions: { col: number; colors: string[] }[] = []; + for (const p of parts) { + const last = regions[regions.length - 1]; + const cells = Array.from({ length: p.n }, () => p.fg); + if (last && last.col + last.colors.length === p.col) last.colors.push(...cells); + else regions.push({ col: p.col, colors: cells }); + } + + const yTop = HEADER_H + PAD + row * LINE_H; + for (const region of regions) { + const len = region.colors.length; + const fullCells = len + (trackLen.get(row) ?? 0); + const period = sheenPeriod(fullCells); + const frames = sheenLoopTicks(period); + const dur = (frames * SHEEN_TICK_MS) / 1000; + for (let i = 0; i < len; i++) { + const base = region.colors[i]!; + const values: string[] = []; + for (let t = 0; t < frames; t++) { + const intensity = sheenIntensity(i, sheenCenter(t, period)); + values.push(intensity > 0 ? lerpHex(base, SHEEN_PEAK, intensity) : base); + } + const x = PAD + (region.col + i) * CHAR_W; + out.push( + ` `, + ); + out.push( + ` `, + ); + out.push(` `); + } + } + } + } + out.push(""); return out.join("\n"); } diff --git a/scripts/render-previews-impl.tsx b/scripts/render-previews-impl.tsx index 67a475c..3321f44 100644 --- a/scripts/render-previews-impl.tsx +++ b/scripts/render-previews-impl.tsx @@ -16,7 +16,7 @@ import { Downloads } from "../src/ui/components/Downloads"; import { footerHints } from "../src/ui/keymap"; import { sourcesByGroup } from "../src/sources/registry"; import { cleanText, formatBytes, formatRelative } from "../src/util/format"; -import { ansiToSvg } from "./ansi-to-svg"; +import { ansiToSvg, type AnsiToSvgOptions } from "./ansi-to-svg"; import type { Config } from "../src/config/config"; import type { DownloadQueue } from "../src/download/queue"; import type { QueueItem } from "../src/download/types"; @@ -97,7 +97,12 @@ function makeStore( }; } -function save(name: string, store: Store, node: React.ReactNode): void { +function save( + name: string, + store: Store, + node: React.ReactNode, + extra: Partial = {}, +): void { const { lastFrame, unmount } = render( {node}, ); @@ -106,7 +111,10 @@ function save(name: string, store: Store, node: React.ReactNode): void { if (!/\x1b\[/.test(frame)) { throw new Error(`${name}: frame has no ANSI colors (FORCE_COLOR didn't take)`); } - writeFileSync(join(OUT_DIR, `${name}.svg`), ansiToSvg(frame, { cols: COLS, title: "torlink" })); + writeFileSync( + join(OUT_DIR, `${name}.svg`), + ansiToSvg(frame, { cols: COLS, title: "torlink", ...extra }), + ); console.log(`preview/${name}.svg`); } @@ -126,7 +134,7 @@ save( {CATEGORIES} - {}} /> + {}} /> @@ -159,7 +167,7 @@ save( - {}} /> + {}} /> newest across all sources @@ -237,4 +245,5 @@ save( , + { shimmer: true }, ); diff --git a/src/ui/components/ProgressBar.tsx b/src/ui/components/ProgressBar.tsx index 840daba..cb4368d 100644 --- a/src/ui/components/ProgressBar.tsx +++ b/src/ui/components/ProgressBar.tsx @@ -1,13 +1,9 @@ import { useEffect, useState } from "react"; import { Text } from "ink"; import { COLOR, RULE, lerpHex } from "../theme"; +import { SHEEN_PEAK, SHEEN_TICK_MS, sheenCenter, sheenIntensity, sheenPeriod } from "../sheen"; const DEEP = "#7c5cd6"; -const SHEEN_PEAK = "#f4efff"; -const RADIUS = 3.5; -const GAP = 8; -const MAX_SHEEN = 0.9; -const TICK_MS = 90; interface Run { color: string; @@ -55,7 +51,7 @@ export function ProgressBar({ const [tick, setTick] = useState(0); useEffect(() => { if (!animate) return; - const timer = setInterval(() => setTick((v) => v + 1), TICK_MS); + const timer = setInterval(() => setTick((v) => v + 1), SHEEN_TICK_MS); timer.unref?.(); return () => clearInterval(timer); }, [animate]); @@ -76,15 +72,12 @@ export function ProgressBar({ ); } - const period = Math.ceil(width + RADIUS * 2) + GAP; - const center = (tick % period) - RADIUS; + const period = sheenPeriod(width); + const center = sheenCenter(tick, period); const cells = Array.from({ length: filled }, (_, i) => { let c = ramp(i / denom, DEEP, COLOR.accent, COLOR.bright); - const d = Math.abs(i - center); - if (d < RADIUS) { - const intensity = 0.5 * (1 + Math.cos((Math.PI * d) / RADIUS)) * MAX_SHEEN; - c = lerpHex(c, SHEEN_PEAK, intensity); - } + const intensity = sheenIntensity(i, center); + if (intensity > 0) c = lerpHex(c, SHEEN_PEAK, intensity); return c; }); diff --git a/src/ui/components/Results.tsx b/src/ui/components/Results.tsx index dfd529d..d8f1c4c 100644 --- a/src/ui/components/Results.tsx +++ b/src/ui/components/Results.tsx @@ -14,7 +14,7 @@ import type { Source, TorrentResult } from "../../sources/types"; type Mode = "list" | "search" | "detail"; -const PLACEHOLDER = "Search, or paste a magnet…"; +const PLACEHOLDER = "Search or paste a magnet link…"; function DetailRow({ label, value }: { label: string; value: ReactNode }) { return ( diff --git a/src/ui/sheen.ts b/src/ui/sheen.ts new file mode 100644 index 0000000..0a00725 --- /dev/null +++ b/src/ui/sheen.ts @@ -0,0 +1,37 @@ +// Single source of truth for the animated progress-bar sheen, shared by the +// live TUI (src/ui/components/ProgressBar.tsx) and the README SVG generator +// (scripts/ansi-to-svg.ts) so the preview renders 1:1 with the terminal. +// +// The highlight is a cosine bell that sweeps across the filled cells. Position +// advances by a fractional SHEEN_SPEED per tick at a fast SHEEN_TICK_MS, so the +// peak glides between cells (intensity-interpolated) instead of stepping a whole +// cell at a time, while the cells themselves stay discrete (pixelated). + +export const SHEEN_PEAK = "#f4efff"; +export const SHEEN_RADIUS = 4.5; // bell half-width, in cells +export const SHEEN_GAP = 8; // dark cells between sweeps +export const SHEEN_TICK_MS = 40; // frame interval (~25fps) +export const SHEEN_SPEED = 0.45; // cells advanced per tick (~11 cells/sec) +export const SHEEN_MAX = 0.9; // peak blend toward SHEEN_PEAK + +/** Total sweep cycle length in cells, for a bar `width` cells wide. */ +export function sheenPeriod(width: number): number { + return Math.ceil(width + SHEEN_RADIUS * 2) + SHEEN_GAP; +} + +/** Fractional center of the bell at a given tick (loops over `period`). */ +export function sheenCenter(tick: number, period: number): number { + return ((tick * SHEEN_SPEED) % period) - SHEEN_RADIUS; +} + +/** Sheen intensity (0..SHEEN_MAX) for cell `i` given the bell `center`. */ +export function sheenIntensity(i: number, center: number): number { + const d = Math.abs(i - center); + if (d >= SHEEN_RADIUS) return 0; + return 0.5 * (1 + Math.cos((Math.PI * d) / SHEEN_RADIUS)) * SHEEN_MAX; +} + +/** Ticks in one full visual loop, for sampling the SVG flipbook 1:1. */ +export function sheenLoopTicks(period: number): number { + return Math.round(period / SHEEN_SPEED); +} diff --git a/src/ui/views/Splash.tsx b/src/ui/views/Splash.tsx index d027b0f..bf06f76 100644 --- a/src/ui/views/Splash.tsx +++ b/src/ui/views/Splash.tsx @@ -50,7 +50,7 @@ export function Splash() { width={barWidth} value="" editing - placeholder="Search, or paste a magnet…" + placeholder="Search or paste a magnet link…" onSubmit={submitQuery} />