feat: new torrent client UI
Some checks are pending
Check / check (push) Waiting to run

fix: better breakpoints code for JS
fix: macOS external player spawning
This commit is contained in:
ThaUnknown 2025-06-25 00:31:02 +02:00
commit 95ed1aeaf1
No known key found for this signature in database
56 changed files with 1772 additions and 96 deletions

View file

@ -1,6 +1,6 @@
{
"name": "ui",
"version": "6.3.72",
"version": "6.4.0",
"license": "BUSL-1.1",
"private": true,
"packageManager": "pnpm@9.14.4",
@ -40,12 +40,14 @@
"tailwindcss": "^3.4.17",
"typescript": "^5.8.3",
"vaul-svelte": "^0.3.2",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vite-plugin-static-copy": "^3.0.2"
},
"type": "module",
"dependencies": {
"@cloudflare/speedtest": "^1.4.1",
"@fontsource-variable/nunito": "^5.2.5",
"@fontsource/geist-mono": "^5.2.6",
"@prgm/sveltekit-progress-bar": "2.0.0",
"@thaunknown/web-irc": "^1.0.3",
"@urql/exchange-auth": "^2.2.1",
@ -59,8 +61,10 @@
"bittorrent-tracker": "10.0.12",
"bottleneck": "^2.19.5",
"clsx": "^2.1.1",
"cobe": "0.6.3",
"date-fns": "^4.1.0",
"debug": "^4.4.1",
"doc999tor-fast-geoip": "^1.1.335",
"dompurify": "^3.2.5",
"events": "^3.3.0",
"idb-keyval": "^6.2.2",
@ -71,6 +75,7 @@
"rollup-plugin-license": "^3.6.0",
"semver": "^7.7.2",
"simple-store-svelte": "^1.0.6",
"svelte-headless-table": "^0.18.3",
"svelte-keybinds": "^1.0.9",
"svelte-persisted-store": "^0.12.0",
"tailwind-merge": "^3.3.0",

View file

@ -14,6 +14,9 @@ importers:
'@fontsource-variable/nunito':
specifier: ^5.2.5
version: 5.2.5
'@fontsource/geist-mono':
specifier: ^5.2.6
version: 5.2.6
'@prgm/sveltekit-progress-bar':
specifier: 2.0.0
version: 2.0.0(@sveltejs/kit@2.21.0(@sveltejs/vite-plugin-svelte@3.1.2(svelte@4.2.19)(vite@5.4.19(terser@5.39.0)))(svelte@4.2.19)(vite@5.4.19(terser@5.39.0)))(svelte@4.2.19)
@ -53,12 +56,18 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cobe:
specifier: 0.6.3
version: 0.6.3
date-fns:
specifier: ^4.1.0
version: 4.1.0
debug:
specifier: ^4.4.1
version: 4.4.1
doc999tor-fast-geoip:
specifier: ^1.1.335
version: 1.1.335
dompurify:
specifier: ^3.2.5
version: 3.2.5
@ -89,6 +98,9 @@ importers:
simple-store-svelte:
specifier: ^1.0.6
version: 1.0.6
svelte-headless-table:
specifier: ^0.18.3
version: 0.18.3(svelte@4.2.19)
svelte-keybinds:
specifier: ^1.0.9
version: 1.0.9
@ -192,6 +204,9 @@ importers:
vite:
specifier: ^5.4.11
version: 5.4.19(terser@5.39.0)
vite-plugin-static-copy:
specifier: ^3.0.2
version: 3.0.2(vite@5.4.19(terser@5.39.0))
packages:
@ -409,6 +424,9 @@ packages:
'@fontsource-variable/nunito@5.2.5':
resolution: {integrity: sha512-XMrSfi1XrnM6HQA+MMdPVY/5tdnG4vamQScaesQRhaboP8g0dEjxbtUJY50KHFTh2MnQP5lHIyDFuMNM4Kb23A==}
'@fontsource/geist-mono@5.2.6':
resolution: {integrity: sha512-I3hsRP+8Gmhk35cwlPAR4w5xqk7e5pro2F1o51ZmB+lN+dPcwN3jYHKN+u0E5AMuiQKpTdkrqfEpvBjzQax3cQ==}
'@gql.tada/cli-utils@1.6.3':
resolution: {integrity: sha512-jFFSY8OxYeBxdKi58UzeMXG1tdm4FVjXa8WHIi66Gzu9JWtCE6mqom3a8xkmSw+mVaybFW5EN2WXf1WztJVNyQ==}
peerDependencies:
@ -1005,6 +1023,9 @@ packages:
peerDependencies:
svelte: ^4.0.0 || ^5.0.0-next.1
cobe@0.6.3:
resolution: {integrity: sha512-WHr7X4o1ym94GZ96h7b1pNemZJacbOzd02dZtnVwuC4oWBaLg96PBmp2rIS1SAhUDhhC/QyS9WEqkpZIs/ZBTg==}
code-red@1.0.4:
resolution: {integrity: sha512-7qJWqItLA8/VPVlKJlFXU+NBlo/qyfs39aJcuMT/2ere32ZqvF5OSxgdM5xOfJJ7O429gg2HM47y8v9P+9wrNw==}
@ -1153,6 +1174,9 @@ packages:
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
doc999tor-fast-geoip@1.1.335:
resolution: {integrity: sha512-/rnNx4yIu84V8i8c0+95fNuVOfSRAGKNJXrSR8PSmrcXDuqzmnLNUsm++8Tqe+c9n1Fsp/3ryATTe5hHgC64hQ==}
doctrine@2.1.0:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
@ -1415,6 +1439,10 @@ packages:
fraction.js@4.3.7:
resolution: {integrity: sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==}
fs-extra@11.3.0:
resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==}
engines: {node: '>=14.14'}
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@ -1736,6 +1764,9 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true
jsonfile@6.1.0:
resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==}
keyv@4.5.4:
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
@ -1941,6 +1972,10 @@ packages:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
p-map@7.0.3:
resolution: {integrity: sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==}
engines: {node: '>=18'}
p2pt@https://codeload.github.com/ThaUnknown/p2pt/tar.gz/9ad7a56ed6ee43f5664ebad33b803702ee349316:
resolution: {tarball: https://codeload.github.com/ThaUnknown/p2pt/tar.gz/9ad7a56ed6ee43f5664ebad33b803702ee349316}
version: 1.5.1
@ -1981,6 +2016,9 @@ packages:
periscopic@3.1.0:
resolution: {integrity: sha512-vKiQ8RRtkl9P+r/+oefh25C3fhybptkHKCZSPlcXiJux2tJF55GnEj3BVn4A5gKfq9NWWXXrxkHBwVPUfH0opw==}
phenomenon@1.6.0:
resolution: {integrity: sha512-7h9/fjPD3qNlgggzm88cY58l9sudZ6Ey+UmZsizfhtawO6E3srZQXywaNm2lBwT72TbpHYRPy7ytIHeBUD/G0A==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -2352,6 +2390,11 @@ packages:
svelte:
optional: true
svelte-headless-table@0.18.3:
resolution: {integrity: sha512-1zVnqXW0dvn6ZceYa94k+ziK+w5Dj9nlWYTQGXBv2JhM0resj9w7CWpclZK1TJwAALfEeH4InPBPO87L5fr+nQ==}
peerDependencies:
svelte: ^4.0.0
svelte-hmr@0.16.0:
resolution: {integrity: sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==}
engines: {node: ^12.20 || ^14.13.1 || >= 16}
@ -2361,6 +2404,11 @@ packages:
svelte-keybinds@1.0.9:
resolution: {integrity: sha512-bQt9azkXX4SgMJpJzYWQB6D0hj45+Ro2+2Awr4YNtjmuRuKdio+Rxuhky5JJyBBfyRQ7YT63nSR3whH4FACv1A==}
svelte-keyed@2.0.0:
resolution: {integrity: sha512-7TeEn+QbJC2OJrHiuM0T8vMBkms3DNpTE+Ir+NtnVBnBMA78aL4f1ft9t0Hn/pBbD/TnIXi4YfjFRAgtN+DZ5g==}
peerDependencies:
svelte: ^4.0.0
svelte-persisted-store@0.12.0:
resolution: {integrity: sha512-BdBQr2SGSJ+rDWH8/aEV5GthBJDapVP0GP3fuUCA7TjYG5ctcB+O9Mj9ZC0+Jo1oJMfZUd1y9H68NFRR5MyIJA==}
engines: {node: '>=0.14'}
@ -2372,11 +2420,21 @@ packages:
peerDependencies:
svelte: ^3.54.0 || ^4.0.0 || ^5.0.0 || ^5.0.0-next.1
svelte-render@2.0.1:
resolution: {integrity: sha512-RpB0SurwXm4xhjvHHtjeqMmvd645FURb79GFOotScOSqnKK5vpqBgoBPGC0pp+E/eZgDSQ9rRAdn/+N4ys1mXQ==}
peerDependencies:
svelte: ^4.0.0
svelte-sonner@0.3.28:
resolution: {integrity: sha512-K3AmlySeFifF/cKgsYNv5uXqMVNln0NBAacOYgmkQStLa/UoU0LhfAACU6Gr+YYC8bOCHdVmFNoKuDbMEsppJg==}
peerDependencies:
svelte: ^3.0.0 || ^4.0.0 || ^5.0.0-next.1
svelte-subscribe@2.0.1:
resolution: {integrity: sha512-eKXIjLxB4C7eQWPqKEdxcGfNXm2g/qJ67zmEZK/GigCZMfrTR3m7DPY93R6MX+5uoqM1FRYxl8LZ1oy4URWi2A==}
peerDependencies:
svelte: ^4.0.0
svelte2tsx@0.7.39:
resolution: {integrity: sha512-NX8a7eSqF1hr6WKArvXr7TV7DeE+y0kDFD7L5JP7TWqlwFidzGKaG415p992MHREiiEWOv2xIWXJ+mlONofs0A==}
peerDependencies:
@ -2430,6 +2488,10 @@ packages:
resolution: {integrity: sha512-zxke8goJQpBeEgD82CXABeMh0LSJcj7CXEd0OHOg45HgcofF7pxNwZm9+RknpxpDhwN4gFpySkApKfFYfRQnUA==}
engines: {node: '>=0.10.0'}
tinyglobby@0.2.14:
resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==}
engines: {node: '>=12.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -2498,6 +2560,10 @@ packages:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
universalify@2.0.1:
resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==}
engines: {node: '>= 10.0.0'}
unordered-array-remove@1.0.2:
resolution: {integrity: sha512-45YsfD6svkgaCBNyvD+dFHm4qFX9g3wRSIVgWVPtm2OCnphvPxzJoe20ATsiNpNJrmzHifnxm+BN5F7gFT/4gw==}
@ -2531,6 +2597,12 @@ packages:
video-deband@1.0.7:
resolution: {integrity: sha512-vwJ2E/e7DfvFlKU5RQ8T8ZEcG7m7A41TIxZ3X57o7Rzw+HSTNyljrtSPJU11UQR2X9wVmAC7WKdOs7zOsxNV6A==}
vite-plugin-static-copy@3.0.2:
resolution: {integrity: sha512-/seLvhUg44s1oU9RhjTZZy/0NPbfNctozdysKcvPovxxXZdI5l19mGq6Ri3IaTf1Dy/qChS4BSR7ayxeu8o9aQ==}
engines: {node: ^18.0.0 || >=20.0.0}
peerDependencies:
vite: ^5.0.0 || ^6.0.0
vite@5.4.19:
resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -2820,6 +2892,8 @@ snapshots:
'@fontsource-variable/nunito@5.2.5': {}
'@fontsource/geist-mono@5.2.6': {}
'@gql.tada/cli-utils@1.6.3(@0no-co/graphqlsp@1.12.16(graphql@16.10.0)(typescript@5.8.3))(@gql.tada/svelte-support@1.0.1(svelte@4.2.19)(typescript@5.8.3))(graphql@16.10.0)(typescript@5.8.3)':
dependencies:
'@0no-co/graphqlsp': 1.12.16(graphql@16.10.0)(typescript@5.8.3)
@ -3492,6 +3566,10 @@ snapshots:
nanoid: 5.1.5
svelte: 4.2.19
cobe@0.6.3:
dependencies:
phenomenon: 1.6.0
code-red@1.0.4:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
@ -3630,6 +3708,8 @@ snapshots:
dlv@1.1.3: {}
doc999tor-fast-geoip@1.1.335: {}
doctrine@2.1.0:
dependencies:
esutils: 2.0.3
@ -4025,6 +4105,12 @@ snapshots:
fraction.js@4.3.7: {}
fs-extra@11.3.0:
dependencies:
graceful-fs: 4.2.11
jsonfile: 6.1.0
universalify: 2.0.1
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@ -4351,6 +4437,12 @@ snapshots:
dependencies:
minimist: 1.2.8
jsonfile@6.1.0:
dependencies:
universalify: 2.0.1
optionalDependencies:
graceful-fs: 4.2.11
keyv@4.5.4:
dependencies:
json-buffer: 3.0.1
@ -4529,6 +4621,8 @@ snapshots:
dependencies:
p-limit: 3.1.0
p-map@7.0.3: {}
p2pt@https://codeload.github.com/ThaUnknown/p2pt/tar.gz/9ad7a56ed6ee43f5664ebad33b803702ee349316:
dependencies:
bittorrent-tracker: 10.0.12
@ -4570,6 +4664,8 @@ snapshots:
estree-walker: 3.0.3
is-reference: 3.0.3
phenomenon@1.6.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@ -4998,12 +5094,23 @@ snapshots:
optionalDependencies:
svelte: 4.2.19
svelte-headless-table@0.18.3(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-keyed: 2.0.0(svelte@4.2.19)
svelte-render: 2.0.1(svelte@4.2.19)
svelte-subscribe: 2.0.1(svelte@4.2.19)
svelte-hmr@0.16.0(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-keybinds@1.0.9: {}
svelte-keyed@2.0.0(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-persisted-store@0.12.0(svelte@4.2.19):
dependencies:
svelte: 4.2.19
@ -5012,10 +5119,19 @@ snapshots:
dependencies:
svelte: 4.2.19
svelte-render@2.0.1(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-subscribe: 2.0.1(svelte@4.2.19)
svelte-sonner@0.3.28(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte-subscribe@2.0.1(svelte@4.2.19):
dependencies:
svelte: 4.2.19
svelte2tsx@0.7.39(svelte@4.2.19)(typescript@5.8.3):
dependencies:
dedent-js: 1.0.1
@ -5102,6 +5218,11 @@ snapshots:
time-stamp@2.2.0: {}
tinyglobby@0.2.14:
dependencies:
fdir: 6.4.4(picomatch@4.0.2)
picomatch: 4.0.2
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -5187,6 +5308,8 @@ snapshots:
has-symbols: 1.1.0
which-boxed-primitive: 1.1.1
universalify@2.0.1: {}
unordered-array-remove@1.0.2: {}
update-browserslist-db@1.1.3(browserslist@4.24.5):
@ -5222,6 +5345,15 @@ snapshots:
rvfc-polyfill: 1.0.7
twgl.js: 5.5.4
vite-plugin-static-copy@3.0.2(vite@5.4.19(terser@5.39.0)):
dependencies:
chokidar: 3.6.0
fs-extra: 11.3.0
p-map: 7.0.3
picocolors: 1.1.1
tinyglobby: 0.2.14
vite: 5.4.19(terser@5.39.0)
vite@5.4.19(terser@5.39.0):
dependencies:
esbuild: 0.21.5

View file

@ -22,6 +22,10 @@
-webkit-app-region: no-drag;
}
.font-mono {
font-family: "Geist Mono", ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New, monospace !important;
}
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;

68
src/app.d.ts vendored
View file

@ -38,17 +38,55 @@ export interface Attachment {
}
export interface TorrentInfo {
peers: number
progress: number
down: number
up: number
name: string
progress: number
size: {
total: number
downloaded: number
uploaded: number
}
speed: {
down: number
up: number
}
time: {
remaining: number
elapsed: number
}
peers: {
seeders: number
leechers: number
wires: number
}
pieces: {
total: number
size: number
}
hash: string
seeders: number
leechers: number
}
export interface PeerInfo {
ip: string
seeder: boolean
client: string
progress: number
size: {
downloaded: number
uploaded: number
}
speed: {
down: number
up: number
}
flags: Array<'incoming' | 'outgoing' | 'utp' | 'encrypted'>
time: number
}
export interface FileInfo {
name: string
size: number
downloaded: number
eta: number
progress: number
selections: number
}
export interface TorrentSettings {
@ -95,8 +133,18 @@ export interface Native {
subtitles: (hash: string, id: number, cb: (subtitle: { text: string, time: number, duration: number }, trackNumber: number) => void) => Promise<void>
errors: (cb: (error: Error) => void) => Promise<void>
chapters: (hash: string, id: number) => Promise<Array<{ start: number, end: number, text: string }>>
torrentStats: (hash: string) => Promise<TorrentInfo>
torrents: () => Promise<TorrentInfo[]>
torrentInfo: (hash: string) => Promise<TorrentInfo>
peerInfo: (hash: string) => Promise<PeerInfo[]>
fileInfo: (hash: string) => Promise<FileInfo[]>
protocolStatus: (hash: string) => Promise<{
dht: boolean
lsd: boolean
pex: boolean
nat: boolean
forwarding: boolean
persisting: boolean
streaming: boolean
}>
setDOH: (dns: string) => Promise<void>
cachedTorrents: () => Promise<string[]>
downloadProgress: (percent: number) => Promise<void>

View file

@ -23,7 +23,7 @@
import { authAggregator, list, progress } from '$lib/modules/auth'
import { click, dragScroll } from '$lib/modules/navigate'
import { liveAnimeProgress } from '$lib/modules/watchProgress'
import { cn, isMobile, since } from '$lib/utils'
import { breakpoints, cn, since } from '$lib/utils'
export let eps: EpisodesResponse | null
export let media: Media
@ -150,7 +150,7 @@
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='h-4 w-4' />
</Button>
{#if !$isMobile}
{#if $breakpoints.md}
{#each pages as { page, type } (page)}
{#if type === 'ellipsis'}
<span class='h-9 w-9 text-center'>...</span>

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import Check from 'svelte-radix/Check.svelte'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.CheckboxItemProps
type $$Events = DropdownMenuPrimitive.CheckboxItemEvents
let className: $$Props['class'] = undefined
export let checked: $$Props['checked'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:checked
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.CheckboxIndicator>
<Check class='h-4 w-4' />
</DropdownMenuPrimitive.CheckboxIndicator>
</span>
<slot />
</DropdownMenuPrimitive.CheckboxItem>

View file

@ -0,0 +1,27 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.ContentProps
let className: $$Props['class'] = undefined
export let sideOffset: $$Props['sideOffset'] = 4
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.Content
{transition}
{transitionConfig}
{sideOffset}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-md focus:outline-none',
className
)}
{...$$restProps}
on:keydown
>
<slot />
</DropdownMenuPrimitive.Content>

View file

@ -0,0 +1,32 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.ItemProps & {
inset?: boolean
}
type $$Events = DropdownMenuPrimitive.ItemEvents
let className: $$Props['class'] = undefined
export let inset: $$Props['inset'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.Item
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className
)}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
{...$$restProps}
>
<slot />
</DropdownMenuPrimitive.Item>

View file

@ -0,0 +1,20 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.LabelProps & {
inset?: boolean
}
let className: $$Props['class'] = undefined
export let inset: $$Props['inset'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.Label
class={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
{...$$restProps}
>
<slot />
</DropdownMenuPrimitive.Label>

View file

@ -0,0 +1,11 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
type $$Props = DropdownMenuPrimitive.RadioGroupProps
export let value: $$Props['value'] = undefined
</script>
<DropdownMenuPrimitive.RadioGroup {...$$restProps} bind:value>
<slot />
</DropdownMenuPrimitive.RadioGroup>

View file

@ -0,0 +1,36 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import DotFilled from 'svelte-radix/DotFilled.svelte'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.RadioItemProps
type $$Events = DropdownMenuPrimitive.RadioItemEvents
let className: $$Props['class'] = undefined
export let value: DropdownMenuPrimitive.RadioItemProps['value']
export { className as class }
</script>
<DropdownMenuPrimitive.RadioItem
class={cn(
'data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className
)}
{value}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerdown
on:pointerleave
on:pointermove
>
<span class='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.RadioIndicator>
<DotFilled class='h-4 w-4 fill-current' />
</DropdownMenuPrimitive.RadioIndicator>
</span>
<slot />
</DropdownMenuPrimitive.RadioItem>

View file

@ -0,0 +1,15 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.SeparatorProps
let className: $$Props['class'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.Separator
class={cn('bg-muted -mx-1 my-1 h-px', className)}
{...$$restProps}
/>

View file

@ -0,0 +1,14 @@
<script lang='ts'>
import type { HTMLAttributes } from 'svelte/elements'
import { cn } from '$lib/utils.js'
type $$Props = HTMLAttributes<HTMLSpanElement>
let className: $$Props['class'] = undefined
export { className as class }
</script>
<span class={cn('ml-auto text-xs tracking-widest opacity-60', className)} {...$$restProps}>
<slot />
</span>

View file

@ -0,0 +1,30 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import { cn, flyAndScale } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.SubContentProps
let className: $$Props['class'] = undefined
export let transition: $$Props['transition'] = flyAndScale
export let transitionConfig: $$Props['transitionConfig'] = {
x: -10,
y: 0
}
export { className as class }
</script>
<DropdownMenuPrimitive.SubContent
{transition}
{transitionConfig}
class={cn(
'bg-popover text-popover-foreground z-50 min-w-[8rem] rounded-md border p-1 shadow-lg focus:outline-none',
className
)}
{...$$restProps}
on:keydown
on:focusout
on:pointermove
>
<slot />
</DropdownMenuPrimitive.SubContent>

View file

@ -0,0 +1,33 @@
<script lang='ts'>
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import ChevronRight from 'svelte-radix/ChevronRight.svelte'
import { cn } from '$lib/utils.js'
type $$Props = DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean
}
type $$Events = DropdownMenuPrimitive.SubTriggerEvents
let className: $$Props['class'] = undefined
export let inset: $$Props['inset'] = undefined
export { className as class }
</script>
<DropdownMenuPrimitive.SubTrigger
class={cn(
'data-[highlighted]:bg-accent data-[state=open]:bg-accent data-[highlighted]:text-accent-foreground data-[state=open]:text-accent-foreground flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
inset && 'pl-8',
className
)}
{...$$restProps}
on:click
on:keydown
on:focusin
on:focusout
on:pointerleave
on:pointermove
>
<slot />
<ChevronRight class='ml-auto h-4 w-4' />
</DropdownMenuPrimitive.SubTrigger>

View file

@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui'
import CheckboxItem from './dropdown-menu-checkbox-item.svelte'
import Content from './dropdown-menu-content.svelte'
import Item from './dropdown-menu-item.svelte'
import Label from './dropdown-menu-label.svelte'
import RadioGroup from './dropdown-menu-radio-group.svelte'
import RadioItem from './dropdown-menu-radio-item.svelte'
import Separator from './dropdown-menu-separator.svelte'
import Shortcut from './dropdown-menu-shortcut.svelte'
import SubContent from './dropdown-menu-sub-content.svelte'
import SubTrigger from './dropdown-menu-sub-trigger.svelte'
const Sub = DropdownMenuPrimitive.Sub
const Root = DropdownMenuPrimitive.Root
const Trigger = DropdownMenuPrimitive.Trigger
const Group = DropdownMenuPrimitive.Group
export {
Sub,
Root,
Item,
Label,
Group,
Trigger,
Content,
Shortcut,
Separator,
RadioItem,
SubContent,
SubTrigger,
RadioGroup,
CheckboxItem,
//
Root as DropdownMenu,
Sub as DropdownMenuSub,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
Group as DropdownMenuGroup,
Content as DropdownMenuContent,
Trigger as DropdownMenuTrigger,
Shortcut as DropdownMenuShortcut,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
RadioGroup as DropdownMenuRadioGroup,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
CheckboxItem as DropdownMenuCheckboxItem
}

View file

@ -8,7 +8,7 @@
import { Comment } from './'
import { client } from '$lib/modules/anilist'
import { isMobile } from '$lib/utils'
import { breakpoints } from '$lib/utils'
export let isLocked = false
export let threadId: number
@ -71,7 +71,7 @@
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='h-4 w-4' />
</Button>
{#if !$isMobile}
{#if $breakpoints.md}
{#each pages as { page, type } (page)}
{#if type === 'ellipsis'}
<span class='h-9 w-9 text-center'>...</span>

View file

@ -12,7 +12,7 @@
import * as Avatar from '$lib/components/ui/avatar'
import * as Tooltip from '$lib/components/ui/tooltip'
import { client, type Media } from '$lib/modules/anilist'
import { isMobile, since } from '$lib/utils'
import { breakpoints, since } from '$lib/utils'
export let media: Media
@ -121,7 +121,7 @@
<Button size='icon' variant='ghost' on:click={() => setPage(currentPage - 1)} disabled={!hasPrev}>
<ChevronLeft class='h-4 w-4' />
</Button>
{#if !$isMobile}
{#if $breakpoints.md}
{#each pages as { page, type } (page)}
{#if type === 'ellipsis'}
<span class='h-9 w-9 text-center'>...</span>

View file

@ -754,15 +754,15 @@
<!-- {($torrentstats.progress * 100).toFixed(1)}% -->
<div class='flex justify-center items-center gap-2'>
<Users size={18} />
{$torrentstats.seeders}
{$torrentstats.peers.seeders}
</div>
<div class='flex justify-center items-center gap-2'>
<ChevronDown size={18} />
{fastPrettyBits($torrentstats.down * 8)}/s
{fastPrettyBits($torrentstats.speed.down * 8)}/s
</div>
<div class='flex justify-center items-center gap-2'>
<ChevronUp size={18} />
{fastPrettyBits($torrentstats.up * 8)}/s
{fastPrettyBits($torrentstats.speed.up * 8)}/s
</div>
</div>
{#if seeking}

View file

@ -4,12 +4,12 @@
import { Button } from '../button'
import { isMobile } from '$lib/utils'
import { breakpoints } from '$lib/utils'
let open = false // 152 x 140
</script>
{#if $isMobile}
{#if !$breakpoints.md}
<div class='shrink-0 z-50 bg-black absolute right-4 bottom-4 w-14 h-[52px] flex rounded-md items-end justify-end overflow-clip transition-[width,height] group-fullscreen/fullscreen:hidden' class:!w-[152px]={open} class:!h-[140px]={open}>
<div class='p-2 grid grid-cols-3 gap-2 shrink-0'>
<slot />

View file

@ -9,7 +9,7 @@
export { className as class }
</script>
<div class='relative w-full overflow-auto'>
<div class='relative w-full overflow-auto h-full'>
<table class={cn('w-full caption-bottom text-sm', className)} {...$$restProps}>
<slot />
</table>

View file

@ -0,0 +1,65 @@
<script lang='ts'>
import ArrowDown from 'svelte-radix/ArrowDown.svelte'
import ArrowUp from 'svelte-radix/ArrowUp.svelte'
import { Button } from '$lib/components/ui/button'
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'
import { cn } from '$lib/utils'
let className: string | undefined | null = ''
export { className as class }
export let props: {
sort: {
order: 'desc' | 'asc' | undefined
toggle: (_: Event) => void
clear: () => void
disabled: boolean
}
}
function handleAscSort (e: Event) {
if (props.sort.order === 'asc') {
return
}
props.sort.toggle(e)
}
function handleDescSort (e: Event) {
if (props.sort.order === 'desc') {
return
}
if (props.sort.order === undefined) {
// We can only toggle, so we toggle from undefined to 'asc' first
props.sort.toggle(e)
}
props.sort.toggle(e) // Then we toggle from 'asc' to 'desc'
}
</script>
{#if !props.sort.disabled}
<div class={cn('flex items-center', className)}>
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild let:builder>
<Button
variant='ghost'
builders={[builder]}
class='h-8 data-[state=open]:bg-accent text-sm px-4 w-full justify-start'
size='sm'>
<slot />
</Button>
</DropdownMenu.Trigger>
<DropdownMenu.Content align='start' sameWidth={true}>
<DropdownMenu.Item on:click={handleAscSort} class='cursor-pointer'>
<ArrowUp class='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Asc
</DropdownMenu.Item>
<DropdownMenu.Item on:click={handleDescSort} class='cursor-pointer'>
<ArrowDown class='mr-2 h-3.5 w-3.5 text-muted-foreground/70' />
Desc
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{:else}
<slot />
{/if}

View file

@ -0,0 +1,2 @@
export { default as NameCell } from './name.svelte'
export { default as ProgressCell } from './progress.svelte'

View file

@ -0,0 +1,5 @@
<script lang='ts'>
export let value: string
</script>
<div class='text-xs font-mono'>{value}</div>

View file

@ -0,0 +1,8 @@
<script lang='ts'>
export let value: number
</script>
<div class='min-w-32 w-full overflow-clip rounded-full bg-secondary h-1.5 mt-1.5'>
<div class='h-full w-full bg-primary transition-transform transform-gpu' style:--tw-translate-x='{(value * 100) - 100}%' />
</div>
<div class='text-xs mt-1 text-muted-foreground'>{(value * 100).toFixed(1)}%</div>

View file

@ -0,0 +1,97 @@
<script lang='ts'>
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import Columnheader from '../columnheader.svelte'
import { NameCell, ProgressCell } from './cells'
import * as Table from '$lib/components/ui/table'
import { server } from '$lib/modules/torrent'
import { cn, fastPrettyBytes } from '$lib/utils'
const table = createTable(server.files, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
})
const columns = table.createColumns([
table.column({
accessor: 'name',
header: 'File Name',
id: 'name',
cell: ({ value }) => createRender(NameCell, { value })
}),
table.column({
accessor: 'size',
header: 'Size',
id: 'size',
cell: ({ value }) => fastPrettyBytes(value)
}),
table.column({
accessor: 'progress',
header: 'Progress',
id: 'progress',
cell: ({ value }) => createRender(ProgressCell, { value })
}),
table.column({ accessor: 'selections', header: 'Selections', id: 'selections' })
])
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
</script>
<div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'>
{#each $headerRows as headerRow, i (i)}
<Subscribe rowAttrs={headerRow.attrs()}>
<Table.Row class='sticky top-0 bg-black z-[2]'>
{#each headerRow.cells as cell (cell.id)}
<Subscribe
attrs={cell.attrs()}
props={cell.props()}
let:attrs
let:props>
<Table.Head {...attrs} class={cn('px-0 first:pl-2 h-12 last:pr-2', cell.id === 'name' && 'w-full')}>
{#if cell.id !== 'flags'}
<Columnheader {props}>
<Render of={cell.render()} />
</Columnheader>
{:else}
<div class='text-sm px-4'>
<Render of={cell.render()} />
</div>
{/if}
</Table.Head>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
</Table.Header>
<Table.Body {...$tableBodyAttrs} class='max-h-full overflow-y-scroll'>
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12'>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'downloaded' || cell.id === 'episode') && 'text-muted-foreground')}>
<Render of={cell.render()} />
</Table.Cell>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class='h-40 text-center'>
No files downloaded yet.
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>

View file

@ -0,0 +1,104 @@
<script lang='ts'>
import createGlobe from 'cobe'
import { lookup } from '$lib/modules/geoip'
import { server } from '$lib/modules/torrent'
import { breakpoints } from '$lib/utils'
const normalize = (val = 0, max = 0, min = 0) => (val - min) / (max - min) || 0
const peers = server.peers
// pick first 64 peers based on download/upload speeds
// cobe limits to 64
// then normalize the speeds to a range of 0-1
$: picked = $peers
.sort((a, b) => (b.speed.down + b.speed.up) - (a.speed.down + a.speed.up))
.slice(0, 64)
// then normalize the speeds to a range of 0-1
$: lowestSpeed = Math.min(...picked.map(p => p.speed.down + p.speed.up))
$: highestSpeed = Math.max(...picked.map(p => p.speed.down + p.speed.up))
$: normalized = picked.map(peer => ({
...peer,
normalizedSpeed: normalize(peer.speed.down + peer.speed.up, highestSpeed, lowestSpeed)
}))
let markers: Record<string, {
location: [number, number]
size: number
} | null>
$: markers = Object.fromEntries(
normalized.map(({ ip }) => [
ip,
null
])
)
function createMarker (ip: string, marker: { location: [number, number], size: number }) {
if (ip in markers) {
markers[ip] = marker
}
}
const maxSize = 0.05
const minSize = 0.02
$: {
for (const { ip, normalizedSpeed } of normalized) {
lookup(ip).then(({ city, ll }) => {
if (!city) {
ll[0] += (Math.random() - 0.5) * 4
ll[1] += (Math.random() - 0.5) * 4
}
createMarker(ip, {
location: ll,
size: Math.min(Math.max(normalizedSpeed * maxSize, minSize), maxSize)
})
}).catch(() => undefined)
}
}
$: size = $breakpoints['3xl'] ? 600 : 400
const scale = 1.5
const oneOverScale = 1 / scale
function makeGlobe (canvas: HTMLCanvasElement) {
const globe = createGlobe(canvas, {
devicePixelRatio: window.devicePixelRatio,
width: size,
height: size,
phi: 0,
theta: 0.1,
dark: 1,
diffuse: 1.4,
mapSamples: 19000,
mapBrightness: 6,
opacity: 0.8,
baseColor: [0.23, 0.23, 0.23],
markerColor: [0.05, 1, 0],
glowColor: [0, 0, 0],
markers: [],
scale,
offset: [size * 0.8 * oneOverScale, size * oneOverScale * 0.4],
onRender: state => {
state.phi = Date.now() * 0.0002 % (Math.PI * 2)
state.width = size
state.height = size
state.offset = [size * 0.8 * oneOverScale, size * oneOverScale * 0.4]
state.markers = Object.values(markers).filter(m => m)
}
})
return {
destroy () {
globe.destroy()
}
}
}
</script>
<canvas use:makeGlobe class='absolute bottom-0 right-0 -z-[1] pointer-events-none' width={size} height={size} />

View file

@ -0,0 +1,6 @@
export { default as Globe } from './globe.svelte'
export { default as Overview } from './overview.svelte'
export { default as PeersTable } from './peers/table.svelte'
export { default as LibraryTable } from './library/table.svelte'
export { default as FilesTable } from './files/table.svelte'

View file

@ -0,0 +1,5 @@
<script lang='ts'>
export let value: number
</script>
<div class='text-sm'>{new Date(value).toLocaleDateString()}</div>

View file

@ -0,0 +1,2 @@
export { default as StatusCell } from './status.svelte'
export { default as NameCell } from './name.svelte'

View file

@ -0,0 +1,5 @@
<script lang='ts'>
export let value: string
</script>
<div class='text-xs font-mono'>{value}</div>

View file

@ -0,0 +1,7 @@
<script lang='ts'>
export let value: boolean
</script>
<div class='flex gap-x-2 items-center'>
<div class='w-2 h-2 rounded-full flex-shrink-0 {value ? 'bg-green-500' : 'bg-blue-500'}' />{value ? 'Completed' : 'In Progress'}
</div>

View file

@ -0,0 +1,117 @@
<script lang='ts'>
import { writable } from 'svelte/store'
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import Columnheader from '../columnheader.svelte'
import { NameCell, StatusCell } from './cells'
import * as Table from '$lib/components/ui/table'
import { cn, fastPrettyBytes } from '$lib/utils'
interface LibraryEntry {
series: string
episode: string
name: string
files: number
size: number
completed: boolean
downloaded: number
}
const data = writable<LibraryEntry[]>([])
const table = createTable(data, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
})
const columns = table.createColumns([
table.column({ accessor: 'series', header: 'Series', id: 'series' }),
table.column({ accessor: 'episode', header: 'Episode', id: 'episode' }),
table.column({
accessor: 'name',
header: 'Torrent Name',
id: 'name',
cell: ({ value }) => createRender(NameCell, { value })
}),
table.column({ accessor: 'files', header: 'Files', id: 'files' }),
table.column({
accessor: 'size',
header: 'Size',
id: 'size',
cell: ({ value }) => fastPrettyBytes(value)
}),
table.column({
accessor: 'completed',
header: 'Status',
id: 'completed',
cell: ({ value }) => createRender(StatusCell, { value })
}),
table.column({
accessor: 'downloaded',
header: 'Downloaded',
id: 'downloaded',
cell: ({ value }) => new Date(value).toLocaleDateString()
})
])
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
</script>
<div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'>
{#each $headerRows as headerRow, i (i)}
<Subscribe rowAttrs={headerRow.attrs()}>
<Table.Row class='sticky top-0 bg-black z-[2]'>
{#each headerRow.cells as cell (cell.id)}
<Subscribe
attrs={cell.attrs()}
props={cell.props()}
let:attrs
let:props>
<Table.Head {...attrs} class={cn('px-0 first:pl-2 h-12 last:pr-2', cell.id === 'name' && 'w-full')}>
{#if cell.id !== 'flags'}
<Columnheader {props}>
<Render of={cell.render()} />
</Columnheader>
{:else}
<div class='text-sm px-4'>
<Render of={cell.render()} />
</div>
{/if}
</Table.Head>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
</Table.Header>
<Table.Body {...$tableBodyAttrs} class='max-h-full overflow-y-scroll'>
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12'>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', (cell.id === 'downloaded' || cell.id === 'episode') && 'text-muted-foreground')}>
<Render of={cell.render()} />
</Table.Cell>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class='h-40 text-center'>
No torrents downloaded yet.
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>

View file

@ -0,0 +1,210 @@
<script lang='ts'>
import Clock from 'lucide-svelte/icons/clock'
import ClockFading from 'lucide-svelte/icons/clock-fading'
import Download from 'lucide-svelte/icons/download'
import HardDrive from 'lucide-svelte/icons/hard-drive'
import HardDriveDownload from 'lucide-svelte/icons/hard-drive-download'
import Link from 'lucide-svelte/icons/link'
import Network from 'lucide-svelte/icons/network'
import Puzzle from 'lucide-svelte/icons/puzzle'
import Timer from 'lucide-svelte/icons/timer'
import Upload from 'lucide-svelte/icons/upload'
import UserRoundMinus from 'lucide-svelte/icons/user-round-minus'
import UserRoundPlus from 'lucide-svelte/icons/user-round-plus'
import Users from 'lucide-svelte/icons/users'
import Wifi from 'lucide-svelte/icons/wifi'
import Status from './status.svelte'
import { server } from '$lib/modules/torrent'
import { fastPrettyBits, fastPrettyBytes, eta, safeLocalStorage } from '$lib/utils'
const stats = server.stats
const protocol = server.protocol
$: torrent = $stats
$: protocols = $protocol
$: completed = torrent.progress === 1
const forwarding = safeLocalStorage<boolean>('torrent-port-forwarding') ?? false
</script>
<div class='max-w-6xl flex flex-col gap-12'>
<div class='flex items-center gap-4'>
<div class='flex-1 w-full'>
<h1 class='text-2xl font-bold truncate text-nowrap'>{torrent.name || 'No Name Provided'}</h1>
<div class='flex items-center gap-2 mt-2'>
<div class='rounded-full px-2.5 py-0.5 text-xs font-bold text-primary-foreground {completed ? 'bg-blue-500 hover:bg-blue-500' : 'bg-green-500 hover:bg-green-500'}'>
{completed ? 'Seeding' : 'Downloading'}
</div>
<span class='text-sm text-muted-foreground'>{torrent.hash}</span>
</div>
</div>
<div class='flex gap-2' />
</div>
<div class='space-y-4'>
<div class='flex items-center justify-between'>
<div class='flex items-center gap-2'>
<HardDriveDownload class='w-5 h-5 mr-1.5' />
<span class='text-2xl font-bold'>Progress</span>
</div>
<span class='text-2xl font-bold'>{(torrent.progress * 100).toFixed(1)}%</span>
</div>
<div class='relative w-full overflow-clip rounded-full bg-secondary h-3'>
<div class='h-full w-full bg-primary transition-transform transform-gpu' style:--tw-translate-x='{(torrent.progress * 100) - 100}%' />
</div>
<div class='grid grid-cols-2 md:grid-cols-4 gap-4 text-sm'>
<div class='flex items-center gap-2'>
<Download class='w-4 h-4 text-green-500 mx-1' />
<div>
<span class='text-muted-foreground'>Downloaded</span>
<div class='font-medium'>{fastPrettyBytes(torrent.size.downloaded)}</div>
</div>
</div>
<div class='flex items-center gap-2'>
<Upload class='w-4 h-4 text-blue-500 mx-1' />
<div>
<span class='text-muted-foreground'>Uploaded</span>
<div class='font-medium'>{fastPrettyBytes(torrent.size.uploaded)}</div>
</div>
</div>
<div class='flex items-center gap-2'>
<HardDrive class='w-4 h-4 text-gray-500 mx-1' />
<div>
<span class='text-muted-foreground'>Total Size</span>
<div class='font-medium'>{fastPrettyBytes(torrent.size.total)}</div>
</div>
</div>
<div class='flex items-center gap-2'>
<Puzzle class='w-4 h-4 text-gray-500 mx-1' />
<div>
<span class='text-muted-foreground'>Pieces</span>
<div class='font-medium'>{torrent.pieces.total} <span class='text-muted-foreground'>×</span> {fastPrettyBytes(torrent.pieces.size)}</div>
</div>
</div>
</div>
</div>
<div class='grid grid-cols-1 xl:grid-cols-3 gap-x-12'>
<div>
<div class='flex flex-col space-y-1.5 py-6'>
<h3 class='text-2xl font-bold leading-none flex items-center gap-2'>
<Wifi class='w-5 h-5 mr-1.5' />
Speed &amp; Transfer
</h3>
</div>
<div class='space-y-4'>
<div class='grid grid-cols-2 gap-4'>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<Download class='w-4 h-4 text-green-500' />
<span class='text-sm font-medium text-muted-foreground'>Download</span>
</div>
<div class='text-2xl font-bold'>{fastPrettyBits(torrent.speed.down * 8)}/s</div>
</div>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<Upload class='w-4 h-4 text-blue-500' />
<span class='text-sm font-medium text-muted-foreground'>Upload</span>
</div>
<div class='text-2xl font-bold'>{fastPrettyBits(torrent.speed.up * 8)}/s</div>
</div>
</div>
</div>
</div>
<div>
<div class='flex flex-col space-y-1.5 py-6'>
<h3 class='text-2xl font-bold leading-none flex items-center gap-2'>
<Clock class='w-5 h-5 mr-1.5' />
Time Information
</h3>
</div>
<div class='space-y-4'>
<div class='grid grid-cols-2 gap-4'>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<ClockFading class='w-4 h-4 text-orange-500' />
<span class='text-sm font-medium text-muted-foreground'>Remaining</span>
</div>
<div class='text-2xl font-bold'>{eta(torrent.time.remaining)}</div>
</div>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<Timer class='w-4 h-4 text-purple-500' />
<span class='text-sm font-medium text-muted-foreground'>Elapsed</span>
</div>
<div class='text-2xl font-bold'>{eta(torrent.time.elapsed)}</div>
</div>
</div>
</div>
</div>
<div>
<div class='flex flex-col space-y-1.5 py-6'>
<h3 class='text-2xl font-bold leading-none flex items-center gap-2'>
<Users class='w-5 h-5 mr-1.5' />
Peers &amp; Connections
</h3>
</div>
<div class='space-y-4'>
<div class='grid grid-cols-3 gap-4'>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<UserRoundPlus class='w-4 h-4 text-green-500' />
<span class='text-sm font-medium text-muted-foreground'>Seeders</span>
</div>
<div class='text-2xl font-bold'>{torrent.peers.seeders}</div>
</div>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<UserRoundMinus class='w-4 h-4 text-blue-500' />
<span class='text-sm font-medium text-muted-foreground'>Leechers</span>
</div>
<div class='text-2xl font-bold'>{torrent.peers.leechers}</div>
</div>
<div class='space-y-2'>
<div class='flex items-center gap-2'>
<Link class='w-4 h-4 text-purple-500' />
<span class='text-sm font-medium text-muted-foreground'>Wires</span>
</div>
<div class='text-2xl font-bold'>{torrent.peers.wires}</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div class='flex flex-col space-y-1.5 py-6'>
<h3 class='text-2xl font-bold leading-none flex items-center gap-2'>
<Network class='w-5 h-5 mr-1.5' />
Protocol Status
</h3>
</div>
<div class='py-6 pt-0'>
<div class='grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-12'>
<div class='space-y-4'>
<h4 class='font-medium'>Network Discovery</h4>
<div class='space-y-3'>
<Status enabled={protocols.dht} title='DHT' description='Distributed Hash Table for peer discovery' />
<Status enabled={protocols.lsd} title='LSD' description='Local Service Discovery on network' />
<Status enabled={protocols.pex} title='PEX' description='Peer Exchange with other clients' />
</div>
</div>
<div class='space-y-4'>
<h4 class='font-medium'>Connection</h4>
<div class='space-y-3'>
<Status enabled={protocols.nat} title='NAT' description='NAT-PMP/UPnP automatic forwarding' />
<Status enabled={forwarding || protocols.forwarding} title='Forwarding' description='Accepting inbound connections' />
</div>
</div>
<div class='space-y-4'>
<h4 class='font-medium'>Storage</h4>
<div class='space-y-3'>
<Status enabled={protocols.persisting} title='Persisting' description='Storing all torrents' />
<Status enabled={protocols.streaming} title='Streaming' description='Downloading only required pieces' />
</div>
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,17 @@
<script lang='ts'>
import { lookup } from '$lib/modules/geoip'
import { codeToEmoji } from '$lib/utils'
export let value: string
</script>
<div class='flex gap-x-2'>
{#await lookup(value) then location}
<div class='font-twemoji text-xl leading-none content-center line-clamp-1'>
{codeToEmoji(location.country)}
</div>
<div class='text-muted-foreground'>
{location.country}
</div>
{/await}
</div>

View file

@ -0,0 +1,48 @@
<script lang='ts'>
import Lock from 'lucide-svelte/icons/lock'
import Shield from 'lucide-svelte/icons/shield'
import Wifi from 'lucide-svelte/icons/wifi'
import WifiOff from 'lucide-svelte/icons/wifi-off'
import { Badge } from '$lib/components/ui/badge'
import * as Tooltip from '$lib/components/ui/tooltip'
export let value: Array<'incoming' | 'outgoing' | 'utp' | 'encrypted'>
const badgeVariants: Record<typeof value[number], string> = {
incoming: 'text-green-500',
outgoing: 'text-blue-500',
utp: 'text-purple-500',
encrypted: 'text-yellow-500'
}
const labels: Record<typeof value[number], string> = {
incoming: 'Incoming',
outgoing: 'Outgoing',
utp: 'uTP',
encrypted: 'Encrypted'
}
</script>
<div class='flex gap-x-2'>
{#each value as flag (flag)}
<Tooltip.Root>
<Tooltip.Trigger>
<Badge variant='secondary' class='p-1 {badgeVariants[flag]}'>
{#if flag === 'incoming'}
<Wifi class='size-3' />
{:else if flag === 'outgoing'}
<WifiOff class='size-3' />
{:else if flag === 'utp'}
<Shield class='size-3' />
{:else if flag === 'encrypted'}
<Lock class='size-3' />
{/if}
</Badge>
</Tooltip.Trigger>
<Tooltip.Content>
<p class='font-semibold'>{labels[flag]}</p>
</Tooltip.Content>
</Tooltip.Root>
{/each}
</div>

View file

@ -0,0 +1,5 @@
export { default as CountryCell } from './country.svelte'
export { default as IpCell } from './ip.svelte'
export { default as SpeedCell } from './speed.svelte'
export { default as ProgressCell } from './progress.svelte'
export { default as FlagsCell } from './flags.svelte'

View file

@ -0,0 +1,8 @@
<script lang='ts'>
export let ip: string
export let seeder: boolean
</script>
<div class='flex gap-x-2 font-mono items-center text-xs'>
<div class='w-2 h-2 rounded-full flex-shrink-0 {seeder ? 'bg-green-500' : 'bg-blue-500'}' />{ip}
</div>

View file

@ -0,0 +1,8 @@
<script lang='ts'>
export let value: number
</script>
<div class='min-w-16 w-full overflow-clip rounded-full bg-secondary h-1.5 mt-1.5'>
<div class='h-full w-full bg-primary transition-transform transform-gpu' style:--tw-translate-x='{(value * 100) - 100}%' />
</div>
<div class='text-xs mt-1 text-muted-foreground'>{(value * 100).toFixed(1)}%</div>

View file

@ -0,0 +1,18 @@
<script lang='ts'>
import Download from 'lucide-svelte/icons/download'
import Upload from 'lucide-svelte/icons/upload'
import { fastPrettyBits } from '$lib/utils'
export let value: number
export let type: 'upload' | 'download'
</script>
<div class='flex gap-x-2 items-center'>
{#if type === 'download'}
<Download class='size-3 text-green-500 mr-0.5' />
{:else}
<Upload class='size-3 text-blue-500 mr-0.5' />
{/if}
{fastPrettyBits(value * 8) + '/s'}
</div>

View file

@ -0,0 +1,136 @@
<script lang='ts'>
import { Render, Subscribe, createRender, createTable } from 'svelte-headless-table'
import { addSortBy } from 'svelte-headless-table/plugins'
import Columnheader from '../columnheader.svelte'
import { CountryCell, FlagsCell, IpCell, ProgressCell, SpeedCell } from './cells'
import * as Table from '$lib/components/ui/table'
import { server } from '$lib/modules/torrent'
import { cn, eta, fastPrettyBytes } from '$lib/utils'
const table = createTable(server.peers, {
sort: addSortBy({ toggleOrder: ['asc', 'desc'] })
})
const columns = table.createColumns([
table.column({
accessor: 'ip',
header: 'IP Address',
id: 'ip',
cell: ({ value, row }) => {
// @ts-expect-error bad typedefs
return createRender(IpCell, { ip: value, seeder: row.original.seeder })
}
}),
table.column({ accessor: 'client', header: 'Client', id: 'client' }),
table.column({
accessor: 'progress',
header: 'Progress',
id: 'progress',
cell: ({ value }) => createRender(ProgressCell, { value })
}),
table.column({
accessor: row => row.speed.down,
header: 'Download',
id: 'down',
cell: ({ value }) => createRender(SpeedCell, { value, type: 'download' })
}),
table.column({
accessor: row => row.speed.up,
header: 'Upload',
id: 'up',
cell: ({ value }) => createRender(SpeedCell, { value, type: 'upload' })
}),
table.column({
accessor: row => row.size.downloaded,
header: 'Downloaded',
id: 'downloaded',
cell: ({ value }) => fastPrettyBytes(value)
}),
table.column({
accessor: row => row.size.uploaded,
header: 'Uploaded',
id: 'uploaded',
cell: ({ value }) => fastPrettyBytes(value)
}),
table.column({
accessor: 'time',
header: 'Time',
id: 'time',
cell: ({ value }) => eta(value)
}),
table.column({
accessor: 'ip',
header: 'Country',
id: 'country',
cell: ({ value }) => createRender(CountryCell, { value })
}),
table.column({
accessor: 'flags',
header: 'Flags',
id: 'flags',
cell: ({ value }) => createRender(FlagsCell, { value })
})
])
const tableModel = table.createViewModel(columns)
const { headerRows, pageRows, tableAttrs, tableBodyAttrs } = tableModel
</script>
<div class='rounded-md border max-w-screen-xl h-full overflow-clip contain-strict'>
<Table.Root {...$tableAttrs} class='max-h-full'>
<Table.Header class='px-5'>
{#each $headerRows as headerRow, i (i)}
<Subscribe rowAttrs={headerRow.attrs()}>
<Table.Row class='sticky top-0 bg-black z-[2]'>
{#each headerRow.cells as cell (cell.id)}
<Subscribe
attrs={cell.attrs()}
props={cell.props()}
let:attrs
let:props>
<Table.Head {...attrs} class={cn('px-0 first:pl-2 h-12 last:pr-2', cell.id === 'progress' && 'w-full')}>
{#if cell.id !== 'flags'}
<Columnheader {props}>
<Render of={cell.render()} />
</Columnheader>
{:else}
<div class='text-sm px-4'>
<Render of={cell.render()} />
</div>
{/if}
</Table.Head>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
</Table.Header>
<Table.Body {...$tableBodyAttrs} class='max-h-full overflow-y-scroll'>
{#if $pageRows.length}
{#each $pageRows as row (row.id)}
<Subscribe rowAttrs={row.attrs()} let:rowAttrs>
<Table.Row {...rowAttrs} class='h-12'>
{#each row.cells as cell (cell.id)}
<Subscribe attrs={cell.attrs()} let:attrs>
<Table.Cell {...attrs} class={cn('px-4 h-14 first:pl-6 last:pr-6 text-nowrap', cell.id === 'time' && 'text-muted-foreground')}>
<Render of={cell.render()} />
</Table.Cell>
</Subscribe>
{/each}
</Table.Row>
</Subscribe>
{/each}
{:else}
<Table.Row>
<Table.Cell colspan={columns.length} class='h-40 text-center'>
No peers connected yet.
</Table.Cell>
</Table.Row>
{/if}
</Table.Body>
</Table.Root>
</div>

View file

@ -0,0 +1,13 @@
<script lang='ts'>
export let enabled = false
export let title = ''
export let description = ''
</script>
<div class='flex items-center gap-2'>
<div class='w-2 h-2 rounded-full {enabled ? 'bg-green-500' : 'bg-red-500' }' />
<div class='mx-1'>
<span class='text-sm'>{title}</span>
<p class='text-xs text-muted-foreground'>{description}</p>
</div>
</div>

View file

@ -0,0 +1,71 @@
import params from 'doc999tor-fast-geoip/build/params.js'
import { binarySearch, firstArrayItem, getNextIp, identity, ipStr2Num, type Format, type indexFile, type ipBlockRecord, type locationRecord } from './utils.ts'
const MASK = ipStr2Num('255.255.255.255')
const viteFixedImportWeTrullyHateViteWithAPassionForNotSupportingBasicFeatures = async <T>(path: string) => {
// previously: return import('doc999tor-fast-geoip/data/locations.json', { with: { type: 'json' } })
const res = await fetch(`/geoip/${path}.json`)
return await res.json() as T
}
const ipCache: Record<string, Format> = {}
const locationCache = viteFixedImportWeTrullyHateViteWithAPassionForNotSupportingBasicFeatures<locationRecord[]>('locations')
async function readFile<T extends Format> (filename: string): Promise<T> {
if (ipCache[filename] !== undefined) {
return await Promise.resolve(ipCache[filename] as T)
}
const content = await viteFixedImportWeTrullyHateViteWithAPassionForNotSupportingBasicFeatures<T>(filename)
ipCache[filename] = content
return content
}
interface IpInfo {
range: [number, number]
country: string
region: string
eu: '0'|'1'
timezone: string
city: string
ll: [number, number]
metro: number
area: number
}
export async function lookup (stringifiedIp: string): Promise<IpInfo> {
const ip = ipStr2Num(stringifiedIp)
const data = await readFile<indexFile>('index')
// IP cannot be NaN
if (Number.isNaN(ip)) throw new Error('IP cannot be NaN')
const rootIndex = binarySearch(data, ip, identity)
// Ip is not in the database, return empty object
if (rootIndex === -1) throw new Error('IP not found in the database')
let nextIp = getNextIp(data, rootIndex, MASK, identity)
const data2 = await readFile<indexFile>('i' + rootIndex)
const index = binarySearch(data2, ip, identity) + rootIndex * params.NUMBER_NODES_PER_MIDINDEX
nextIp = getNextIp(data2, index, nextIp, identity)
const data3 = await readFile<ipBlockRecord[]>('' + index)
const index1 = binarySearch(data3, ip, firstArrayItem)
const ipData = data3[index1]!
if (!ipData[1]) throw new Error("IP doesn't any region nor country associated")
nextIp = getNextIp<ipBlockRecord>(data3, index1, nextIp, firstArrayItem)
const location = (await locationCache)[ipData[1]]!
return {
range: [ipData[0], nextIp] as [number, number],
country: location[0],
region: location[1],
eu: location[5],
timezone: location[4],
city: location[2],
ll: [ipData[2], ipData[3]] as [number, number],
metro: location[3],
area: ipData[4]
}
}

View file

@ -0,0 +1,49 @@
type extractKeyFunction<recordType> = (record: recordType) => number
export type indexFile = number[]
export type ipBlockRecord = [number, number|null, number, number, number]
export type Format = indexFile | ipBlockRecord[] | locationRecord[]
export type locationRecord = [string, string, string, number, string, '0' | '1']
export function identity (item: number): number {
return item
}
export function binarySearch<recordType> (list: recordType[], item: number, extractKey: extractKeyFunction<recordType>): number {
let low = 0
let high = list.length - 1
while (true) {
const i = Math.round((high - low) / 2) + low
if (item < extractKey(list[i]!)) {
if (i === high && i === low) {
return -1 // Item is lower than the first item
} else if (i === high) {
high = low
} else {
high = i
}
} else if (item >= extractKey(list[i]!) && (i === (list.length - 1) || item < extractKey(list[i + 1]!))) {
return i
} else {
low = i
}
}
}
export function ipStr2Num (stringifiedIp: string): number {
return stringifiedIp.split('.')
.map(e => parseInt(e)).reduce((acc, val, index) => acc + val * Math.pow(256, 3 - index), 0)
}
export function firstArrayItem (item: ipBlockRecord): number {
return item[0]
}
export function getNextIp<recordType = number> (data: recordType[], index: number, currentNextIp: number, extractKey: extractKeyFunction<recordType>): number {
if (index < (data.length - 1)) {
return extractKey(data[index + 1]!)
} else {
return currentNextIp
}
}

View file

@ -24,6 +24,29 @@ const dummyFiles = [
// id: 1
// }
]
// function makeRandomPeer (): PeerInfo {
// const ip = `${rnd(256)}.${rnd(256)}.${rnd(256)}.${rnd(256)}:${rnd(65536)}`
// return {
// ip,
// seeder: Math.random() < 0.5,
// client: ['qBittorrent 4.5.4', 'WebTorrent 1.0.0', 'Transmission 3.00', 'Deluge 2.1.1', 'μTorrent 3.5.5', 'Vuze 5.7.7.0', 'Azureus 5.7.6.0'].sort(() => Math.random() - 0.5)[0]!,
// progress: Math.random(),
// size: {
// downloaded: rnd(1000000),
// uploaded: rnd(1000000)
// },
// speed: {
// down: rnd(1000),
// up: rnd(1000)
// },
// time: rnd(1000),
// flags: (['encrypted', 'utp', 'incoming', 'outgoing'] as const).filter(() => Math.random() < 0.5).slice(0, 3)
// }
// }
// const dummyPeerInfo: PeerInfo[] = []
// for (let i = 0; i < 100; i++) {
// dummyPeerInfo.push(makeRandomPeer())
// }
export default Object.assign<Native, Partial<Native>>({
authAL: (url: string) => {
@ -80,7 +103,7 @@ export default Object.assign<Native, Partial<Native>>({
{ start: 1.0 * 60 * 1000, end: 1.2 * 60 * 1000, text: 'Chapter 1' },
{ start: 1.4 * 60 * 1000, end: 88 * 1000, text: 'Chapter 2 ' }
],
version: async () => 'v6.3.0',
version: async () => 'v6.4.0',
updateSettings: async () => undefined,
setDOH: async () => undefined,
cachedTorrents: async () => ['40a9047de61859035659e449d7b84286934486b0'],
@ -88,12 +111,30 @@ export default Object.assign<Native, Partial<Native>>({
setHideToTray: async () => undefined,
transparency: async () => undefined,
setZoom: async () => undefined,
// @ts-expect-error yeah
navigate: async (cb) => { globalThis.___navigate = cb },
navigate: async () => undefined,
downloadProgress: async () => undefined,
updateProgress: async () => undefined,
torrentStats: async (): Promise<TorrentInfo> => ({ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }),
torrents: async (): Promise<TorrentInfo[]> => [{ peers: rnd(), seeders: rnd(), leechers: rnd(), progress: Math.random(), down: rnd(100000000), up: rnd(100000000), name: 'Amebku.webm', downloaded: rnd(100000), hash: '1234567890abcdef', size: 1234567890, eta: rnd() }],
torrentInfo: async (): Promise<TorrentInfo> => ({
name: '',
progress: 0,
size: { total: 0, downloaded: 0, uploaded: 0 },
speed: { down: 0, up: 0 },
time: { remaining: 0, elapsed: 0 },
peers: { seeders: 0, leechers: 0, wires: 0 },
pieces: { total: 0, size: 0 },
hash: ''
}),
fileInfo: async () => [],
peerInfo: async () => [],
protocolStatus: async () => ({
dht: false,
lsd: false,
pex: false,
nat: false,
forwarding: false,
persisting: false,
streaming: false
}),
defaultTransparency: () => false,
errors: async () => undefined,
debug: async () => undefined

View file

@ -5,33 +5,72 @@ import { persisted } from 'svelte-persisted-store'
import native from '../native'
import { w2globby } from '../w2g/lobby'
import type { TorrentFile, TorrentInfo } from '../../../app'
import type { FileInfo, PeerInfo, TorrentFile, TorrentInfo } from '$lib/../app'
import type { Media } from '../anilist'
const defaultTorrentInfo: TorrentInfo = {
name: '',
progress: 0,
size: { total: 0, downloaded: 0, uploaded: 0 },
speed: { down: 0, up: 0 },
time: { remaining: 0, elapsed: 0 },
peers: { seeders: 0, leechers: 0, wires: 0 },
pieces: { total: 0, size: 0 },
hash: ''
}
const defaultProtocolStatus = { dht: false, lsd: false, pex: false, nat: false, forwarding: false, persisting: false, streaming: false }
export const server = new class ServerClient {
last = persisted<{media: Media, id: string, episode: number} | null>('last-torrent', null)
active = writable<Promise<{media: Media, id: string, episode: number, files: TorrentFile[]}| null>>()
downloaded = writable(this.cachedSet())
stats = readable<TorrentInfo>({ peers: 0, down: 0, up: 0, progress: 0, downloaded: 0, eta: 0, hash: '', leechers: 0, name: '', seeders: 0, size: 0 }, set => {
stats = readable(defaultTorrentInfo, set => {
let listener = 0
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.torrentStats(id))
listener = setTimeout(update, 1000)
if (id) set(await native.torrentInfo(id))
listener = setTimeout(update, 200)
}
update()
return () => clearTimeout(listener)
})
list = readable<TorrentInfo[]>([], set => {
protocol = readable(defaultProtocolStatus, set => {
let listener = 0
const update = async () => {
set(await native.torrents())
listener = setTimeout(update, 1000)
const id = (await get(this.active))?.id
if (id) set(await native.protocolStatus(id))
listener = setTimeout(update, 5000)
}
update()
return () => clearTimeout(listener)
})
peers = readable<PeerInfo[]>([], set => {
let listener = 0
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.peerInfo(id))
listener = setTimeout(update, 5000)
}
update()
return () => clearTimeout(listener)
})
files = readable<FileInfo[]>([], set => {
let listener = 0
const update = async () => {
const id = (await get(this.active))?.id
if (id) set(await native.fileInfo(id))
listener = setTimeout(update, 5000)
}
update()

View file

@ -9,6 +9,42 @@ export function cn (...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
type MediaQuery<Query extends Record<string, string> = Record<string, string>> = {
[K in keyof Query]?: boolean | string;
}
function calculateMedia (mqls: Record<string, MediaQueryList>) {
const media: MediaQuery = {}
for (const [key, query] of Object.entries(mqls)) {
media[key] = query.matches
}
return media
}
const mediaQueries = {
sm: '(min-width: 640px)',
md: '(min-width: 768px)',
lg: '(min-width: 1024px)',
xl: '(min-width: 1280px)',
'2xl': '(min-width: 1536px)',
'3xl': '(min-width: 1920px)',
'4xl': '(min-width: 2560px)',
'5xl': '(min-width: 3840px)',
'6xl': '(min-width: 5120px)'
} as const
export const breakpoints = readable<MediaQuery<typeof mediaQueries>>({}, set => {
const ctrl = new AbortController()
const mqls: Record<string, MediaQueryList> = {}
const updateMedia = () => set(calculateMedia(mqls))
for (const [key, query] of Object.entries(mediaQueries)) {
mqls[key] = window.matchMedia(query)
mqls[key].addEventListener('change', updateMedia, { signal: ctrl.signal })
}
updateMedia()
return () => ctrl.abort()
})
interface FlyAndScaleParams {
y?: number
x?: number
@ -89,15 +125,7 @@ export const debounce = <T extends (...args: any[]) => unknown>(
}
}
const mql = typeof matchMedia !== 'undefined' ? matchMedia('(min-width: 768px)') : null
export const isMobile = readable(!mql?.matches, set => {
const check: ({ matches }: { matches: boolean }) => void = ({ matches }) => set(!matches)
mql?.addEventListener('change', check)
return () => mql?.removeEventListener('change', check)
})
const formatter = new Intl.RelativeTimeFormat('en')
const formatterShort = new Intl.RelativeTimeFormat('en', { style: 'short' })
const ranges: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
years: 3600 * 24 * 365,
months: 3600 * 24 * 30,
@ -119,16 +147,33 @@ export function since (date: Date) {
}
return 'now'
}
export function eta (date: Date) {
const secondsElapsed = (date.getTime() - Date.now()) / 1000
for (const _key in ranges) {
const key = _key as Intl.RelativeTimeFormatUnit
if ((ranges[key] ?? 0) < Math.abs(secondsElapsed)) {
const delta = secondsElapsed / (ranges[key] ?? 0)
return formatterShort.format(Math.round(delta), key)
export function eta (seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return '0s'
const units = [
{ label: 'y', secs: 31536000 },
{ label: 'mo', secs: 2592000 },
{ label: 'd', secs: 86400 },
{ label: 'h', secs: 3600 },
{ label: 'm', secs: 60 },
{ label: 's', secs: 1 }
]
let remaining = Math.floor(seconds)
const parts: string[] = []
for (const { label, secs } of units) {
if (remaining >= secs) {
const value = Math.floor(remaining / secs)
parts.push(`${value}${label}`)
remaining %= secs
// Only show up to two largest units (e.g., "1h 2m", "2m 3s")
if (parts.length === 2) break
}
}
return 'now'
// If nothing matched, show "0s"
return parts.length ? parts.join(' ') : '0s'
}
const bytes = [' B', ' kB', ' MB', ' GB', ' TB']
export function fastPrettyBytes (num: number) {

View file

@ -1,6 +1,7 @@
<script lang='ts'>
import '../app.css'
import '@fontsource-variable/nunito'
import '@fontsource/geist-mono'
import '$lib/modules/navigate'
import { ProgressBar } from '@prgm/sveltekit-progress-bar'
import { toast } from 'svelte-sonner'

View file

@ -0,0 +1,51 @@
<script lang='ts'>
import SettingsNav from '$lib/components/SettingsNav.svelte'
import { Separator } from '$lib/components/ui/separator'
import { Globe } from '$lib/components/ui/torrentclient'
import { dragScroll } from '$lib/modules/navigate'
const items = [
{
title: 'Overview',
href: '/app/client/'
},
{
title: 'Files',
href: '/app/client/files/'
},
{
title: 'Peers',
href: '/app/client/peers/'
},
{
title: 'Library',
href: '/app/client/library/'
},
{
title: 'Settings',
href: '/app/settings/client/'
}
]
</script>
<div class='space-y-6 p-10 pb-0 w-full h-full flex flex-col min-w-0'>
<div class='space-y-0.5'>
<h2 class='text-2xl font-bold'>Torrent Client</h2>
<p class='text-muted-foreground'>
Monitor your torrents, and configure settings for your torrent client.
</p>
</div>
<Separator class='my-6' />
<div class='flex flex-col lg:flex-row gap-x-12 grow min-h-0'>
<aside class='lg:grow lg:max-w-60 flex flex-col'>
<SettingsNav {items} />
<div class='mt-auto text-xs text-muted-foreground px-4 sm:px-2 py-5 flex flex-row lg:flex-col font-light gap-0.5 gap-x-4 flex-wrap'>
<div>WebTorrent v2.6.8</div>
</div>
</aside>
<div class='flex-1 overflow-y-scroll' use:dragScroll>
<Globe />
<slot />
</div>
</div>
</div>

View file

@ -1,41 +1,7 @@
<script lang='ts'>
import * as Table from '$lib/components/ui/table'
import { dragScroll } from '$lib/modules/navigate'
import { server } from '$lib/modules/torrent'
import { fastPrettyBits, fastPrettyBytes, eta as _eta } from '$lib/utils'
const list = server.list
import { Overview } from '$lib/components/ui/torrentclient'
</script>
<div class='flex flex-col items-center w-full h-full overflow-y-auto px-5 my-10' use:dragScroll>
<Table.Root>
<Table.Header>
<Table.Row class='[&>*]:p-4 [&>*]:font-bold'>
<Table.Head>Name</Table.Head>
<Table.Head class='w-[100px]'>Progress</Table.Head>
<Table.Head class='w-[100px]'>Size</Table.Head>
<Table.Head class='w-[100px]'>Done</Table.Head>
<Table.Head class='w-[110px]'>Download</Table.Head>
<Table.Head class='w-[110px]'>Upload</Table.Head>
<Table.Head class='w-[110px]'>ETA</Table.Head>
<Table.Head class='w-[100px]'>Seeders</Table.Head>
<Table.Head class='w-[100px]'>Leechers</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{#each $list as { name, progress, size, down, up, eta, seeders, leechers, peers }, i (i)}
<Table.Row class='[&>*]:p-4'>
<Table.Cell>{name ?? '?'}</Table.Cell>
<Table.Cell>{(progress * 100).toFixed(1)}%</Table.Cell>
<Table.Cell>{fastPrettyBytes(size)}</Table.Cell>
<Table.Cell>{fastPrettyBytes(size * progress)}</Table.Cell>
<Table.Cell>{fastPrettyBits(down * 8)}/s</Table.Cell>
<Table.Cell>{fastPrettyBits(up * 8)}/s</Table.Cell>
<Table.Cell>{_eta(new Date(Date.now() + eta)) ?? 'Done'}</Table.Cell>
<Table.Cell>{seeders}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
<Table.Cell>{leechers}<span class='text-muted-foreground'>/{peers}</span></Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
<div class='flex flex-col h-full'>
<Overview />
</div>

View file

@ -0,0 +1,5 @@
<script lang='ts'>
import { FilesTable } from '$lib/components/ui/torrentclient'
</script>
<FilesTable />

View file

@ -0,0 +1,5 @@
<script lang='ts'>
import { LibraryTable } from '$lib/components/ui/torrentclient'
</script>
<LibraryTable />

View file

@ -0,0 +1,5 @@
<script lang='ts'>
import { PeersTable } from '$lib/components/ui/torrentclient'
</script>
<PeersTable />

View file

@ -15,7 +15,7 @@
import { dedupeAiring } from '$lib/modules/anilist'
import { authAggregator, list } from '$lib/modules/auth'
import { dragScroll } from '$lib/modules/navigate'
import { cn, isMobile } from '$lib/utils'
import { cn, breakpoints } from '$lib/utils'
const query = authAggregator.schedule()
@ -127,15 +127,17 @@
{@const sameMonth = isSameMonth(now, day.date)}
<div>
<div class='flex flex-col text-xs py-3 h-48' class:opacity-30={!sameMonth}>
{#if $isMobile}
{#if !$breakpoints.md}
<Drawer.Root shouldScaleBackground portal='html'>
<Drawer.Trigger class='h-full flex flex-col'>
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{day.number}
</div>
<div class='px-3 mt-auto text-ellipsis overflow-hidden text-nowrap w-full'>
{episodes.length} eps
</div>
{#if episodes.length}
<div class='px-3 mt-auto text-ellipsis overflow-hidden text-nowrap w-full'>
{episodes.length} ep{episodes.length > 1 ? 's' : ''}
</div>
{/if}
</Drawer.Trigger>
<Drawer.Content tabindex={null}>
<Drawer.Header>
@ -165,8 +167,6 @@
<div class={cn('w-6 h-6 flex items-center justify-center font-bold mx-3', isToday(day.date) && 'bg-[rgb(61,180,242)] rounded-full')}>
{day.number}
</div>
{/if}
{#if !$isMobile}
<div class='mt-auto'>
{#each episodes.length > 6 ? episodes.slice(0, 5) : episodes as episode, i (i)}
{@const status = _list(episode)}

View file

@ -1,5 +1,16 @@
{
"compilerOptions": {
"paths": {
"lucide-svelte/dist/Icon.svelte": [
"./node_modules/lucide-svelte/dist/Icon.svelte"
],
"$lib": [
"./src/lib"
],
"$lib/*": [
"./src/lib/*"
]
},
"typeRoots": [
// these overrides are required, because we want a custom typed eventemitter, importing node types in any fashion will fully override the typed event emitter, making life a pain
// disabling type acquisition does NOT prevent type acquisition from working, WE LOVE TYPESCRIPT, INDUSTRY LEADING TECHNOLOGY

View file

@ -3,6 +3,7 @@ import { resolve } from 'node:path'
import { sveltekit } from '@sveltejs/kit/vite'
import license from 'rollup-plugin-license'
import { defineConfig } from 'vite'
import { viteStaticCopy } from 'vite-plugin-static-copy'
export default defineConfig({
plugins: [
@ -13,6 +14,14 @@ export default defineConfig({
output: resolve(import.meta.dirname, './build/LICENSE.txt'),
includeSelf: true
}
}),
viteStaticCopy({
targets: [
{ // VITE IS DOG AND DOESNT SUPPORT DYNAMIC JSON IMPORTS
src: 'node_modules/doc999tor-fast-geoip/data/*.json',
dest: 'geoip/'
}
]
})
],
resolve: {