mirror of
https://github.com/ThaUnknown/miru.git
synced 2026-01-12 02:01:26 +00:00
fix: better breakpoints code for JS fix: macOS external player spawning
This commit is contained in:
commit
95ed1aeaf1
56 changed files with 1772 additions and 96 deletions
|
|
@ -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",
|
||||
|
|
|
|||
132
pnpm-lock.yaml
132
pnpm-lock.yaml
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
68
src/app.d.ts
vendored
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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}
|
||||
/>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
49
src/lib/components/ui/dropdown-menu/index.ts
Normal file
49
src/lib/components/ui/dropdown-menu/index.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
65
src/lib/components/ui/torrentclient/columnheader.svelte
Normal file
65
src/lib/components/ui/torrentclient/columnheader.svelte
Normal 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}
|
||||
2
src/lib/components/ui/torrentclient/files/cells/index.ts
Normal file
2
src/lib/components/ui/torrentclient/files/cells/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as NameCell } from './name.svelte'
|
||||
export { default as ProgressCell } from './progress.svelte'
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
export let value: string
|
||||
</script>
|
||||
|
||||
<div class='text-xs font-mono'>{value}</div>
|
||||
|
|
@ -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>
|
||||
97
src/lib/components/ui/torrentclient/files/table.svelte
Normal file
97
src/lib/components/ui/torrentclient/files/table.svelte
Normal 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>
|
||||
104
src/lib/components/ui/torrentclient/globe.svelte
Normal file
104
src/lib/components/ui/torrentclient/globe.svelte
Normal 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} />
|
||||
6
src/lib/components/ui/torrentclient/index.ts
Normal file
6
src/lib/components/ui/torrentclient/index.ts
Normal 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'
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
export let value: number
|
||||
</script>
|
||||
|
||||
<div class='text-sm'>{new Date(value).toLocaleDateString()}</div>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { default as StatusCell } from './status.svelte'
|
||||
export { default as NameCell } from './name.svelte'
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
export let value: string
|
||||
</script>
|
||||
|
||||
<div class='text-xs font-mono'>{value}</div>
|
||||
|
|
@ -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>
|
||||
117
src/lib/components/ui/torrentclient/library/table.svelte
Normal file
117
src/lib/components/ui/torrentclient/library/table.svelte
Normal 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>
|
||||
210
src/lib/components/ui/torrentclient/overview.svelte
Normal file
210
src/lib/components/ui/torrentclient/overview.svelte
Normal 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 & 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 & 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>
|
||||
|
|
@ -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>
|
||||
48
src/lib/components/ui/torrentclient/peers/cells/flags.svelte
Normal file
48
src/lib/components/ui/torrentclient/peers/cells/flags.svelte
Normal 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>
|
||||
5
src/lib/components/ui/torrentclient/peers/cells/index.ts
Normal file
5
src/lib/components/ui/torrentclient/peers/cells/index.ts
Normal 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'
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
18
src/lib/components/ui/torrentclient/peers/cells/speed.svelte
Normal file
18
src/lib/components/ui/torrentclient/peers/cells/speed.svelte
Normal 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>
|
||||
136
src/lib/components/ui/torrentclient/peers/table.svelte
Normal file
136
src/lib/components/ui/torrentclient/peers/table.svelte
Normal 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>
|
||||
13
src/lib/components/ui/torrentclient/status.svelte
Normal file
13
src/lib/components/ui/torrentclient/status.svelte
Normal 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>
|
||||
71
src/lib/modules/geoip/index.ts
Normal file
71
src/lib/modules/geoip/index.ts
Normal 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]
|
||||
}
|
||||
}
|
||||
49
src/lib/modules/geoip/utils.ts
Normal file
49
src/lib/modules/geoip/utils.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
51
src/routes/app/client/+layout.svelte
Normal file
51
src/routes/app/client/+layout.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
5
src/routes/app/client/files/+page.svelte
Normal file
5
src/routes/app/client/files/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
import { FilesTable } from '$lib/components/ui/torrentclient'
|
||||
</script>
|
||||
|
||||
<FilesTable />
|
||||
5
src/routes/app/client/library/+page.svelte
Normal file
5
src/routes/app/client/library/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
import { LibraryTable } from '$lib/components/ui/torrentclient'
|
||||
</script>
|
||||
|
||||
<LibraryTable />
|
||||
5
src/routes/app/client/peers/+page.svelte
Normal file
5
src/routes/app/client/peers/+page.svelte
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<script lang='ts'>
|
||||
import { PeersTable } from '$lib/components/ui/torrentclient'
|
||||
</script>
|
||||
|
||||
<PeersTable />
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Reference in a new issue