@banou/ripple 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +102 -187
  3. package/fkn.json +1 -1
  4. package/index.js +110 -73
  5. package/package.json +3 -1
  6. package/sw.js +1 -1
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2023 Banou
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,212 +1,127 @@
1
- # Ripple, the torrent app that respect your privacy
1
+ # Ripple
2
+
3
+ Download and stream torrents in your browser. Ripple runs a real BitTorrent engine
4
+ ([libtorrent](https://www.libtorrent.org), compiled to WebAssembly) in the page, reaches peers
5
+ through the [FKN](https://fkn.app) relay, and plays a video while it downloads. There is nothing to
6
+ install and no Ripple server.
7
+
8
+ - Live at [torrent.fkn.app](https://torrent.fkn.app), and inside FKN at
9
+ [fkn.app/app/npm:@banou/ripple](https://fkn.app/app/npm:@banou/ripple)
10
+ - Published on npm as [`@banou/ripple`](https://www.npmjs.com/package/@banou/ripple)
11
+
12
+ ## Features
13
+
14
+ - **Add a magnet or a `.torrent`.** Paste it, drop it, or open it from your system once Ripple is
15
+ installed as an app, which then opens `magnet:` links and `.torrent` files.
16
+ - **Watch while it downloads**, with subtitles, a choice of audio track and a Normalize volume
17
+ switch.
18
+ - **Pick the files you want** before the download starts.
19
+ - **Keep files in the browser or in a folder on your disk**, and save one file, or a zip of
20
+ several.
21
+ - **Create a torrent** from files or a folder (v1, v2 or hybrid, private with a source tag, web
22
+ seeds) and seed it straight from those files.
23
+ - **Your library on every device.** Signed in to FKN, your list and its thumbnails sync, encrypted
24
+ in your browser before they leave it.
25
+ - **Share links** that open the player or a download page, which other sites can embed.
26
+ - **One engine for all your tabs.** One tab runs it and the others use it.
27
+ - **Speed limits** for everything or per torrent, and the peers, trackers, files and all-time share
28
+ ratio of each torrent.
29
+ - **No Ripple account and no analytics.**
30
+
31
+ ## How it is built
32
+
33
+ - **UI**: React 19, routed with React Router 7 and styled with Emotion, in `src/router` and
34
+ `src/components`.
35
+ - **Engine**: [`libtorrent-wasm`](https://www.npmjs.com/package/libtorrent-wasm) in a Web Worker
36
+ (`src/torrent/worker.ts`), driven from the page by `src/torrent/client.ts`. One tab holds it
37
+ under a Web Lock and the others reach it over a BroadcastChannel (`engine-election.ts`,
38
+ `engine-share.ts`).
39
+ - **Network**: `@fkn/lib/net` and `@fkn/lib/dgram`, carried through the FKN relay (`relayWorker`
40
+ bridges the worker). Inbound peers reach a port the relay reserves.
41
+ - **Storage**: OPFS, or a folder you grant (`src/torrent/hybrid-storage.ts`). The list, resume data
42
+ and thumbnails are in IndexedDB.
43
+ - **Player**: [`@banou/media-player`](https://www.npmjs.com/package/@banou/media-player) at
44
+ `/watch` (`src/router/embed.tsx`), with libav-wasm and jassub.
45
+ - **Sync**: `src/torrent/use-cloud-backup.ts` and `src/torrent/thumbnail-sync.ts`, under `ripple/`
46
+ in the FKN account's storage.
47
+
48
+ ## Development
49
+
50
+ Use Node 24 (`.node-version` names 24.21.0).
2
51
 
3
- THE app that allows you to download torrents and stream video files from the safety of your browser!
4
-
5
- ## Embedding
6
-
7
- `/watch` and `/download` are the pages another site puts in an iframe. Both take a magnet and the
8
- same query; the path chooses which one renders. `/watch` plays the file, `/download` delivers it.
9
-
10
- | param | value | meaning |
11
- | --- | --- | --- |
12
- | `m` | the packed magnet, base64url | what Ripple writes today |
13
- | `magnet` | base64 of the magnet URI | the original form, read forever |
14
- | `fileIndex` | a file index | the file `/watch` plays; the fallback `/download` uses |
15
- | `files` | `all`, `3`, `0-4`, `0,2,5` | what `/download` delivers |
16
- | `f` | a packed file list | optional preview, `/download` only |
17
-
18
- One of `m` or `magnet` is required. `m` wins if both are present.
19
-
20
- There is no `mode` parameter. It used to choose the page and is now neither written nor read, so a
21
- link still carrying one gets the page its PATH names and the parameter is ignored.
22
-
23
- ### The two magnet forms
24
-
25
- `magnet=<base64 of the magnet URI>` is the original, and it is **permanent**. Every link ever handed
26
- out with it keeps working, and an embedder that finds it easier to write can keep writing it. Nothing
27
- about it has changed.
28
-
29
- `m=` is the same torrent packed smaller: the infohash as raw bytes rather than 40 hex characters, and
30
- the rest of the query deflated against a fixed table of the announce URLs that public magnets
31
- overwhelmingly share. It is base64url, so a query string carries it without escaping anything. Across
32
- a corpus covering every magnet form Ripple accepts, the median link is **69% shorter**; a five-tracker
33
- release goes from a 549-character URL to 148. A magnet using only private trackers has nothing to
34
- match against and still comes out around 42% shorter, on the strength of the packed infohash alone.
35
-
36
- Ripple writes whichever of the two is shorter, which is almost always `m`. The digit is a version:
37
- the table it compresses against can never be edited, so if it ever needs to change the parameter
38
- becomes `m2` and `m` keeps decoding the way it always did.
39
-
40
- ### Parameter order
41
-
42
- Order never matters when READING a link. When writing one, Ripple puts the plainest parts first:
43
-
44
- ```
45
- /watch?fileIndex=3&m=AQAIraWnphg6rh4J2DHfZ0jVZglaEAMWq8GZeSWpORSmTXxFBwA
46
- /download?files=2&m=AQAIraWnphg6rh4J2DHfZ0jVZglaEAMWq8GZeSWpORSmTXxFBwA
47
- /download?m=AQAIraWnphg6rh4J2DHfZ0jVZglaEAMWq8GZeSWpORSmTXxFBwA&f=AWNSC87MK0nN0YdQ
52
+ ```sh
53
+ npm ci
54
+ npm run dev # http://localhost:4560
55
+ npm run build # the site and the npm package, into build/
56
+ npm test # unit tests (vitest, node)
57
+ npm run test:browser # component tests in Chrome
58
+ npm run typecheck
59
+ npm run lint # vp lint
48
60
  ```
49
61
 
50
- The path, then which files, then the packed torrent, then `f`. The torrent used to lead, from when
51
- it was the only thing in the link; now that everything around it is base64url or an index, leading
52
- with it buried the one part a person can read behind forty characters of noise. A URL is read from
53
- the left and truncated from the right, so `f`, the longest parameter and the one a reader cares
54
- least about, goes last.
55
-
56
- ### `f`, the file list
57
-
58
- A magnet names a torrent and nothing else, so a download link normally opens on "Reading the torrent
59
- from the network" for as long as metadata takes. `f` closes that gap by carrying the file list the
60
- sender already had, and the page shows it immediately, marked **from the link**.
62
+ The Playwright suites build first, then drive the built site in Chrome:
61
63
 
62
- It is a preview and Ripple treats it as one. It never decides what gets downloaded: the button stays
63
- disabled until real metadata arrives, the download itself is resolved against that metadata, and the
64
- per-file buttons are not rendered at all while the list is only the link's claim. A link that
65
- describes a torrent inaccurately therefore costs a reader a wrong line on screen for a few seconds,
66
- and can never cost them the wrong file on disk.
64
+ | command | what it covers |
65
+ | --- | --- |
66
+ | `npm run test:e2e:local` | the app, needing nothing but the browser (and ffmpeg) |
67
+ | `npm run test:e2e:machine` | live figures keep their width, which depends on the machine's fonts |
68
+ | `npm run test:e2e:swarm` | the engine against real public swarms |
69
+ | `npm run test:e2e:rig` | the whole chain against a local swarm, see [tests/swarm-rig](./tests/swarm-rig/README.md) |
67
70
 
68
- Ripple writes it only on `/download`, only when it has the list, and only when it fits: a
69
- 12-episode season costs about 172 characters and a 48-file season about 416. Past a budget it is
70
- left off entirely rather than pushing the link past what a chat message will carry. Absent, `f`
71
- changes nothing, so a link without it behaves exactly as it always has.
71
+ The swarm and rig suites need a headful Chrome, because headless Chromium stalls the engine at
72
+ 0 B/s. Run them under `xvfb-run -a -s "-screen 0 1280x720x24"` so no window opens.
72
73
 
73
- Putting the whole `.torrent` in the URL instead does not work, and the reason is arithmetic rather
74
- than encoding: piece hashes are around 94% of a torrent and are 20 bytes of SHA1 per piece, so they
75
- are incompressible. A 12-episode season is a 28,512-character URL and a 40 GB remux is 68,676. The
76
- file list is the part that is both small and worth having.
74
+ A push to `main` deploys [torrent.fkn.app](https://torrent.fkn.app) (Cloudflare Pages), and
75
+ publishes to npm when `package.json` names a version the registry does not have yet
76
+ (`.github/workflows/publish-lib.yml`). Each published version is signed for FKN with `fkn-sign`.
77
77
 
78
- ### `/watch`
79
-
80
- Plays `fileIndex` (0 if absent) in the media player, with the filename, peer count and transfer
81
- rates drawn over the video.
82
-
83
- The mode used to be a parameter, and for a while Ripple wrote `mode=watch` out in full so that a
84
- link said what it did rather than being told apart by an absence. The path says it for free and in
85
- the part of a URL that is read first, so the parameter is gone from both halves and a link still
86
- carrying one gets the page its path names.
78
+ ## Embedding
87
79
 
88
- **This route was `/embed` until 2026-09-09 and the old path is gone rather than redirected.** A link
89
- written against it 404s to the SPA shell; rewrite it as `/watch` with the query untouched.
80
+ `/watch` and `/download` are the pages another site puts in an iframe. Both take a magnet and the
81
+ same query; the path chooses which one renders. `/watch` plays a file, `/download` delivers files.
90
82
 
91
- ### `/download`
83
+ | param | value | meaning |
84
+ | --- | --- | --- |
85
+ | `magnet` | base64 of the magnet URI | the form to write; permanent |
86
+ | `m` | the packed magnet, base64url | what Ripple's own share links write |
87
+ | `fileIndex` | a file index | the file `/watch` plays (0 if absent); the fallback `/download` uses |
88
+ | `files` | `all`, `3`, `0-4`, `0,2,5` | what `/download` delivers |
92
89
 
93
- A download page: the release name, the size of the selection, and one button. One file is delivered
94
- as that file; anything more is delivered as a single `.zip`, written straight through to the
95
- browser's own downloader without ever being held in memory.
90
+ One of `magnet` or `m` is required, and `m` is read first when both are present. `magnet=` is the
91
+ original form and every link written with it keeps working. `m=` is the same torrent packed into a
92
+ much shorter link.
96
93
 
97
94
  ```
98
- /download?magnet=<base64> the whole torrent, as a zip
99
- /download?magnet=<base64>&files=3 just file 3
100
- /download?magnet=<base64>&files=0-4 files 0 to 4 inclusive, as a zip
101
- /download?magnet=<base64>&files=0,2,5 those three, as a zip
102
- /download?magnet=<base64>&fileIndex=3 same as files=3, so a watch URL becomes a download by
103
- swapping /watch for /download and nothing else
95
+ /watch?magnet=<base64>&fileIndex=3 plays file 3
96
+ /download?magnet=<base64> the whole torrent
97
+ /download?magnet=<base64>&files=3 just file 3
98
+ /download?magnet=<base64>&files=0-4 files 0 to 4 inclusive, as a zip
99
+ /download?magnet=<base64>&files=0,2,5 those three, as a zip
100
+ /download?magnet=<base64>&fileIndex=3 same as files=3
104
101
  ```
105
102
 
106
- `files` outranks `fileIndex`. Indices the torrent does not have are dropped rather than clamped, and
107
- a selection that resolves to nothing says so instead of quietly downloading the whole torrent.
108
-
109
- ### What an embedder has to grant
103
+ One file is delivered as that file, and more than one as a single `.zip`. `files` outranks
104
+ `fileIndex`. Indices the torrent does not have are dropped, and a selection that resolves to
105
+ nothing says so instead of downloading the whole torrent.
110
106
 
111
- **The frame needs `allow-downloads` if it is sandboxed at all.** Sandbox flags on a nested browsing
112
- context are the union of the parent's set and the child's, so a flag the embedder withheld cannot be
113
- restored from inside, and Chrome then refuses the download *silently*: the navigation is dropped, no
114
- event fires and nothing throws. There is no `downloads` feature in Permissions-Policy, so `allow=`
115
- is not a lever here; the `sandbox` attribute is the only one.
107
+ **A sandboxed frame needs `allow-downloads`**, or the browser drops the download without an error.
108
+ Leaving out `sandbox` works too.
116
109
 
117
110
  ```html
118
111
  <iframe src="https://torrent.fkn.app/download?magnet=..."
119
112
  sandbox="allow-scripts allow-same-origin allow-downloads"></iframe>
120
113
  ```
121
114
 
122
- Omitting `sandbox` entirely works too. When the page detects it is framed by another origin it
123
- offers a link to open itself top-level, which is the way out if the embedder cannot grant the flag.
124
-
125
- Two other consequences of being framed, both handled: Chrome refuses `showSaveFilePicker` in a
126
- cross-origin frame, so the page does not ask for one, and delivery goes through the service worker
127
- instead.
115
+ Why each part works the way it does is in [docs/embedding.md](./docs/embedding.md).
128
116
 
129
- ## Tests
130
-
131
- | command | what it covers | cost |
132
- | --- | --- | --- |
133
- | `npm test` | pure logic in node | ~1s |
134
- | `npm run test:browser` | components in real Chrome | ~2s |
135
- | `npx tsc --noEmit` | types | ~1s |
136
- | `npx vp lint` | oxlint, type aware | ~1s |
137
- | `npm run test:download` | the download page against a real torrent, end to end | ~30s |
138
- | `npm run test:e2e` | the engine against real swarms | minutes, headful |
139
- | `npm run test:e2e:rig` | the whole chain against a local swarm, see below | ~90s, headful under Xvfb |
117
+ ## Privacy
140
118
 
141
- The playwright suites run headful on purpose: headless Chromium stalls the engine at a flat 0 B/s in
142
- every topology, which makes anything torrent-shaped unmeasurable rather than merely slow.
119
+ Ripple needs no account and runs no analytics. Your list and your files stay in your browser, or in
120
+ a folder you choose. Signed in to FKN, your list and its thumbnails sync to your FKN account,
121
+ encrypted in your browser first. Peers see the FKN relay's address, not yours. The
122
+ [privacy page](https://torrent.fkn.app/privacy) lists what Ripple keeps
123
+ (`src/router/privacy.tsx`).
143
124
 
144
- ### The swarm rig
145
-
146
- `e2e:rig` downloads a torrent that exists only on this machine. The seeders are transmission daemons
147
- on `127.0.0.2` and up, the relay is a local webvpn build, and the broker is a local fkn-client web
148
- build, so the only source of a byte is the fleet the magnet names. The public swarm cannot give that:
149
- byte-identical code measured 14.7 s to 73.4 s to first frame there.
150
-
151
- | piece | where | port |
152
- | --- | --- | --- |
153
- | broker (fkn-client `web`, development build) | `http://localhost:5234/api` | 5234 |
154
- | relay (webvpn release build) | WebTransport, HTTP, WebSocket on `127.0.0.1` | 5433, 5434, 5443 |
155
- | ripple (`build/`, served) | `http://localhost:5460` | 5460 |
156
- | seeders (transmission 4) | `127.0.0.2:52600` and up, rpc `9400` and up | |
157
-
158
- None of it overlaps fkn/local's rig (1234, 3000, 4433, 4560, 8443), so the two run side by side. The
159
- ports live in `tests/swarm-rig/topology.json`.
160
-
161
- It needs three checkouts, and an fkn-client worktree of its own, because the rig rewrites that
162
- checkout's `.env.local` files and build output:
163
-
164
- ```sh
165
- git -C ~/dev/horionsoftware/fkn-client worktree add --detach ~/dev/fkn-client-rig origin/main
166
- (cd ~/dev/horionsoftware/webvpn && cargo build --release)
167
-
168
- export RIPPLE_RIG_FKN_CLIENT=~/dev/fkn-client-rig
169
- export RIPPLE_RIG_WEBVPN=~/dev/horionsoftware/webvpn
170
- export RIPPLE_RIG_LOCAL=~/dev/horionsoftware/local
171
- export RIPPLE_CHROME=$(command -v google-chrome-stable)
172
- export RIG_TRANSMISSION=$(nix build --no-link --print-out-paths 'nixpkgs#transmission_4')
173
-
174
- nix shell nixpkgs#xvfb-run -c env -u WAYLAND_DISPLAY -u NIXOS_OZONE_WL \
175
- xvfb-run -a -s "-screen 0 1280x720x24" npm run test:e2e:rig
176
- ```
125
+ ## License
177
126
 
178
- `test:e2e:rig` runs three steps:
179
-
180
- 1. `scripts/rig-prepare.mjs` builds `@fkn/lib` with `VITE_WEB_ORIGIN=http://localhost:5234` and the
181
- web app as the broker, then checks what each build baked in. It refuses a main worktree.
182
- 2. `npm run build`, which sees `RIPPLE_RIG_FKN_CLIENT` and resolves `@fkn/lib` to that local build
183
- (the published lib has `https://fkn.app` baked in, and no setting of ripple's can move it). The
184
- build warns when it does this. Afterwards `build/` points at the rig's broker, so rebuild without
185
- the variable before serving it for anything else.
186
- 3. `playwright.rig.config.ts` starts the relay (the environment `byo.sh webvpn --export` prints, moved
187
- to the rig's ports, with a fresh certificate and `FREE_RATE_BYTES_PER_SEC` at 1 GiB/s), the broker
188
- and ripple, then runs `tests/swarm-rig.spec.ts`.
189
-
190
- The spec has three tests, run in order:
191
-
192
- - **Configuration.** The relay answers. The RUNNING relay's environment allows private targets and
193
- carries the raised rate. `/api` serves the broker's own document, not the SPA fallback.
194
- - **The swarm arm.** It makes a 60 s 720p fixture with ffmpeg (no private flag, which would stop
195
- seeders serving metadata to a magnet). It starts N seeders with staggered adds and opens `/watch`
196
- for the magnet in a fresh regular profile. It then asserts that the engine held the whole file,
197
- that OPFS holds it piece for piece and by SHA-256, that a frame was painted, and that the seeders
198
- uploaded at least the file. It prints time to first byte, first frame and completion from the add.
199
- - **The control.** The same fleet comes up and goes down, and the same visit runs for 30 s. It must
200
- see 0 bytes in the engine and in OPFS, and the swarm arm's own assertion must FAIL on it.
201
-
202
- The seeders run under a shell that kills them when the test process is gone, so a run killed
203
- outright leaves nothing on their addresses. Without that shell, all four outlived a SIGKILL.
204
-
205
- Knobs: `RIPPLE_RIG_SEEDERS` (default 4), `RIPPLE_RIG_BUDGET_MS` (swarm arm, 120000),
206
- `RIPPLE_RIG_CONTROL_MS` (30000), `RIPPLE_RIG_STATE` (fixture and seeder state, default
207
- `$TMPDIR/ripple-swarm-rig`). The config refuses to start when `WAYLAND_DISPLAY` or `NIXOS_OZONE_WL`
208
- is set, because the Nix Chrome wrapper then opens a real window even under `xvfb-run`.
209
-
210
- The engine's DHT is on, and it announces the infohash through the relay. So the control can see a
211
- stray peer (1 peer in two of three runs, 0 bytes each time). This is why the seeders' own upload
212
- count is part of the claim.
127
+ MIT, see [LICENSE](./LICENSE).
package/fkn.json CHANGED
@@ -1 +1 @@
1
- {"v":0,"app":"fkn:app:1c7clnsv53zt7dr7q7rcbt455hxauykaxl4gibkuoao24dfmk2eqq","name":"Ripple","version":"0.0.22","key":"vB+5jsZMRya/m8nc1Ba6qNELaua1jUjbGj90dEzuaF0=","issuedAt":1792076904,"perSource":["npm:@banou/ripple"],"url":"https://torrent.fkn.app/","contentsDigest":"LdUvlNcOAGkz1OH2lQERVrDgtwCV3xlCFBSOn9YKiV8=","sig":"z2w2X2OMuZVGXVzk304Jjpu3coD8eY+uSyB3yPDrU7RFNGX7/BE5JIU0B9E/oIO/UX2Ny5QpC31VvvnNPmoYBw==","contents":{"name":"@banou/ripple","version":"0.0.22","alg":"sha256","files":{"README.md":"BhsiYnbFSdDOWa6Mzt2EjUghpTfW5AQmhg+xkIck48A=","_headers":"/3GnajpvIIUp907PK8pIPx9H2wEmJgfEbrC3BKNLlgo=","app.webmanifest":"gIWmbT8RLQZNhLkGKZKO8zLWTEX3F1Dj7RAjIr1zEcQ=","assets/empty-BvRk9kiK.js":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","assets/libtorrent-DY7aMUNo.js":"iL3qzB3N3U7HOI1KoUvlK9MxK43UlFsSEz9qVOG4Qi0=","assets/logo.png":"/u2ZiOWTOJxhO6AxbamaptNtn+kUxlrAiPncAEyqwIM=","assets/logo.svg":"3Ciuaqc0v2MjXzWy8x8yLaYwZsDdM0m1H42lzdQPKTg=","assets/sintel.torrent":"TI/a0EFLR2dUag+S/j1mCmbtwyRxh056HPqXEgMXuEo=","assets/worker-DqSNLC3T.js":"FuWv4sMcQhm+PGsvtCLbv3facgy5FNQ20fzwWe9v3qI=","build-B7te3F-u.js":"R7BWxWTweMWYj6fVhUIDPAcGhMkmlWoq2zyRTe9wFN4=","index.html":"NzDOFOXPmq6jpxfz7igvOO2ti9cLkmZSJm1K3NhBxNE=","index.js":"JYUbZ7BmLKcwbdG0t71FyPsDsD3lZwXp/p2TY0bfNKQ=","jassub/default.woff2":"iGkpkDcHxbsosHzS7taZIbPV71xJxzoN0eGH6/ZUakw=","jassub/fonts.js":"JFrcfQn7ONAEVBPA8HdbKDW9YM3BYoIKvVpwHPdwMhw=","jassub/jassub-assets.js":"LkOyTls8XUtOqkH3PzaW5tDCgxL/e+3exAOIUP0F53Q=","jassub/jassub-worker-modern.wasm":"2ryQh6ZZOS3g/IbRZ8h6CF/HUA8xRabX9L8kMsxlDAM=","jassub/jassub-worker.js":"bakeHatafNtt0otborw+KQ/nnI5yRZ9aH7AUU9dnFS4=","jassub/jassub-worker.wasm":"IEZIYFRcqKpiSk7Fbv9FqzgDd89yR8MfQDY0IHeDbtE=","jassub/worker.js":"GkJ90cst1LkPx1b3tixhjcZBxzQ/o0h3VQMtYQJ3hA4=","libav-jspi.wasm":"2UHGqYWee4PUfv9bW+YRWXeZvhxkqSLVjNKt0Q6bgoE=","libav-worker.js":"UcXP9IK8X+1BraCfqCNNEIVE33wn5EYf9P10KNeZupY=","libav.wasm":"0GJptWeBO93kFB18dlf1YWZspC0QjZ60hEv+4O4fPGU=","package.json":"RSpVdGThvd5cJsDW8CDf+gh9vYbIQvKcg2fdnRJF8ho=","remote-DvdN47iT.js":"sGSSUKnjoHkfVqnVZ5r1E1NPaQgilhGahOZLnnhXesw=","sw.js":"P/FKNtmFm32g2e6EPA9hWa8pjYbPwpyJGQD96U56/xU=","volume-normalizer-worklet.js":"uERAQN4GaSzVMTY5hfsqvFH25HBOohimVr3HwLUM3WM="},"types":{"README.md":"text/markdown","_headers":"application/octet-stream","app.webmanifest":"application/manifest+json","assets/empty-BvRk9kiK.js":"text/javascript","assets/libtorrent-DY7aMUNo.js":"text/javascript","assets/logo.png":"image/png","assets/logo.svg":"image/svg+xml","assets/sintel.torrent":"application/octet-stream","assets/worker-DqSNLC3T.js":"text/javascript","build-B7te3F-u.js":"text/javascript","index.html":"text/html","index.js":"text/javascript","jassub/default.woff2":"font/woff2","jassub/fonts.js":"text/javascript","jassub/jassub-assets.js":"text/javascript","jassub/jassub-worker-modern.wasm":"application/wasm","jassub/jassub-worker.js":"text/javascript","jassub/jassub-worker.wasm":"application/wasm","jassub/worker.js":"text/javascript","libav-jspi.wasm":"application/wasm","libav-worker.js":"text/javascript","libav.wasm":"application/wasm","package.json":"application/json","remote-DvdN47iT.js":"text/javascript","sw.js":"text/javascript","volume-normalizer-worklet.js":"text/javascript"}},"keys":{"app":"fkn:app:1c7clnsv53zt7dr7q7rcbt455hxauykaxl4gibkuoao24dfmk2eqq","serial":1790278249316,"issuedAt":1790278249,"founder":{"kind":"fkn","seed":"fsqkqngttg4s2gpm23qiz5nonk"},"signer":"fkn-1","name":"Ripple","members":[{"key":"vB+5jsZMRya/m8nc1Ba6qNELaua1jUjbGj90dEzuaF0=","since":1790278249}],"sig":"1ulbdoxt48ngFVZw6Oufds/QhcKE2+KRG7ZQqShFVPeiPyoLGH2IR3m8CmeSOxe3tA7oTmAGKSc4vWZ6pAuFDQ=="}}
1
+ {"v":0,"app":"fkn:app:1c7clnsv53zt7dr7q7rcbt455hxauykaxl4gibkuoao24dfmk2eqq","name":"Ripple","version":"0.0.23","key":"vB+5jsZMRya/m8nc1Ba6qNELaua1jUjbGj90dEzuaF0=","issuedAt":1792086367,"perSource":["npm:@banou/ripple"],"url":"https://torrent.fkn.app/","contentsDigest":"TsanK8KS0pclvVi0y/XgBdZhX7HP+xrg6InO5vmfCqU=","sig":"ATtFoey2Db21mRqpz+kIrLOnsnSvTHUgXd8uszgaW1PuQVgz+e631o6/8Ex4aItvJqLfjX1G6O4SP7ZyrnmACQ==","contents":{"name":"@banou/ripple","version":"0.0.23","alg":"sha256","files":{"LICENSE":"ltuFZrZFyUvlPEfixC2u36goYudRNNWKEccYye+T9yY=","README.md":"si/2UKFBl2/DeGj/bcVo9jCbmxSgEpyTU/ysagk9zcM=","_headers":"/3GnajpvIIUp907PK8pIPx9H2wEmJgfEbrC3BKNLlgo=","app.webmanifest":"gIWmbT8RLQZNhLkGKZKO8zLWTEX3F1Dj7RAjIr1zEcQ=","assets/empty-BvRk9kiK.js":"47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=","assets/libtorrent-DY7aMUNo.js":"iL3qzB3N3U7HOI1KoUvlK9MxK43UlFsSEz9qVOG4Qi0=","assets/logo.png":"/u2ZiOWTOJxhO6AxbamaptNtn+kUxlrAiPncAEyqwIM=","assets/logo.svg":"3Ciuaqc0v2MjXzWy8x8yLaYwZsDdM0m1H42lzdQPKTg=","assets/sintel.torrent":"TI/a0EFLR2dUag+S/j1mCmbtwyRxh056HPqXEgMXuEo=","assets/worker-DqSNLC3T.js":"FuWv4sMcQhm+PGsvtCLbv3facgy5FNQ20fzwWe9v3qI=","build-B7te3F-u.js":"R7BWxWTweMWYj6fVhUIDPAcGhMkmlWoq2zyRTe9wFN4=","index.html":"NzDOFOXPmq6jpxfz7igvOO2ti9cLkmZSJm1K3NhBxNE=","index.js":"mdrDb1FqfowHUvuk/33vgi6a0x+qzdIRBBuiFfZG6zs=","jassub/default.woff2":"iGkpkDcHxbsosHzS7taZIbPV71xJxzoN0eGH6/ZUakw=","jassub/fonts.js":"JFrcfQn7ONAEVBPA8HdbKDW9YM3BYoIKvVpwHPdwMhw=","jassub/jassub-assets.js":"LkOyTls8XUtOqkH3PzaW5tDCgxL/e+3exAOIUP0F53Q=","jassub/jassub-worker-modern.wasm":"2ryQh6ZZOS3g/IbRZ8h6CF/HUA8xRabX9L8kMsxlDAM=","jassub/jassub-worker.js":"bakeHatafNtt0otborw+KQ/nnI5yRZ9aH7AUU9dnFS4=","jassub/jassub-worker.wasm":"IEZIYFRcqKpiSk7Fbv9FqzgDd89yR8MfQDY0IHeDbtE=","jassub/worker.js":"GkJ90cst1LkPx1b3tixhjcZBxzQ/o0h3VQMtYQJ3hA4=","libav-jspi.wasm":"2UHGqYWee4PUfv9bW+YRWXeZvhxkqSLVjNKt0Q6bgoE=","libav-worker.js":"UcXP9IK8X+1BraCfqCNNEIVE33wn5EYf9P10KNeZupY=","libav.wasm":"0GJptWeBO93kFB18dlf1YWZspC0QjZ60hEv+4O4fPGU=","package.json":"P67kFT/TCXkXmEyldBbd8IOqedrWZf8wHmBg1KNVwx8=","remote-DvdN47iT.js":"sGSSUKnjoHkfVqnVZ5r1E1NPaQgilhGahOZLnnhXesw=","sw.js":"HehHawnlUVYWifbIdz6Lea/4fh0ouRDqO8FNuIn7sbk=","volume-normalizer-worklet.js":"uERAQN4GaSzVMTY5hfsqvFH25HBOohimVr3HwLUM3WM="},"types":{"LICENSE":"application/octet-stream","README.md":"text/markdown","_headers":"application/octet-stream","app.webmanifest":"application/manifest+json","assets/empty-BvRk9kiK.js":"text/javascript","assets/libtorrent-DY7aMUNo.js":"text/javascript","assets/logo.png":"image/png","assets/logo.svg":"image/svg+xml","assets/sintel.torrent":"application/octet-stream","assets/worker-DqSNLC3T.js":"text/javascript","build-B7te3F-u.js":"text/javascript","index.html":"text/html","index.js":"text/javascript","jassub/default.woff2":"font/woff2","jassub/fonts.js":"text/javascript","jassub/jassub-assets.js":"text/javascript","jassub/jassub-worker-modern.wasm":"application/wasm","jassub/jassub-worker.js":"text/javascript","jassub/jassub-worker.wasm":"application/wasm","jassub/worker.js":"text/javascript","libav-jspi.wasm":"application/wasm","libav-worker.js":"text/javascript","libav.wasm":"application/wasm","package.json":"application/json","remote-DvdN47iT.js":"text/javascript","sw.js":"text/javascript","volume-normalizer-worklet.js":"text/javascript"}},"keys":{"app":"fkn:app:1c7clnsv53zt7dr7q7rcbt455hxauykaxl4gibkuoao24dfmk2eqq","serial":1790278249316,"issuedAt":1790278249,"founder":{"kind":"fkn","seed":"fsqkqngttg4s2gpm23qiz5nonk"},"signer":"fkn-1","name":"Ripple","members":[{"key":"vB+5jsZMRya/m8nc1Ba6qNELaua1jUjbGj90dEzuaF0=","since":1790278249}],"sig":"1ulbdoxt48ngFVZw6Oufds/QhcKE2+KRG7ZQqShFVPeiPyoLGH2IR3m8CmeSOxe3tA7oTmAGKSc4vWZ6pAuFDQ=="}}
package/index.js CHANGED
@@ -19077,6 +19077,7 @@ var ky = {
19077
19077
  audioTracks: [],
19078
19078
  selectedAudioTrack: void 0,
19079
19079
  selectAudioTrack: () => {},
19080
+ quality: null,
19080
19081
  hideUI: !1,
19081
19082
  setHideUI: () => {},
19082
19083
  hasMedia: !1,
@@ -20850,65 +20851,74 @@ ${$b}
20850
20851
  }
20851
20852
 
20852
20853
  `, dx = () => {
20853
- let e = Y(), t = Y((e) => e.playbackRate), n = Y((e) => e.audioTracks), r = Y((e) => e.selectedAudioTrack), i = Y((e) => e.selectAudioTrack), a = Y((e) => e.volumeNormalizer), [o, s] = (0, v.useState)(0), { open: c, toggle: l, containerRef: u, pending: d, failed: f, runSelect: p } = tx({ onReset: () => s(0) }), m = (t) => {
20854
- e.setPlaybackRate(t), s(0);
20855
- }, h = (e) => {
20856
- e !== void 0 && p(e, () => i(e));
20857
- }, g = (e) => () => {
20858
- s(e);
20854
+ let e = Y(), t = Y((e) => e.playbackRate), n = Y((e) => e.audioTracks), r = Y((e) => e.selectedAudioTrack), i = Y((e) => e.selectAudioTrack), a = Y((e) => e.quality), o = Y((e) => e.volumeNormalizer), [s, c] = (0, v.useState)(0), { open: l, toggle: u, containerRef: d, pending: f, failed: p, runSelect: m } = tx({ onReset: () => c(0) }), h = (t) => {
20855
+ e.setPlaybackRate(t), c(0);
20856
+ }, g = (e) => {
20857
+ e !== void 0 && m(e, () => i(e));
20858
+ }, _ = (e) => {
20859
+ a && m(e ?? null, () => a.select(e === void 0 ? null : String(e)));
20860
+ }, y = a ? a.options.length + +!!a.offLabel : 0, b = a?.selectedId == null ? a?.offLabel : a.options.find(({ id: e }) => e === a.selectedId)?.label, x = (e) => () => {
20861
+ c(e);
20859
20862
  };
20860
20863
  return /* @__PURE__ */ G("div", {
20861
20864
  css: ux,
20862
- ref: u,
20865
+ ref: d,
20863
20866
  children: [
20864
20867
  /* @__PURE__ */ W(xb, {
20865
20868
  id: "settings",
20866
- disabled: c,
20869
+ disabled: l,
20867
20870
  text: /* @__PURE__ */ W("button", {
20868
20871
  className: "settings",
20869
20872
  type: "button",
20870
- onClick: l,
20873
+ onClick: u,
20871
20874
  "aria-label": "Settings",
20872
- "aria-expanded": c,
20875
+ "aria-expanded": l,
20873
20876
  children: /* @__PURE__ */ W(Lo, {})
20874
20877
  }),
20875
20878
  toolTipText: /* @__PURE__ */ W("span", { children: "Settings" })
20876
20879
  }),
20877
- c && o === 0 && /* @__PURE__ */ G("div", {
20880
+ l && s === 0 && /* @__PURE__ */ G("div", {
20878
20881
  className: "popover menu",
20879
20882
  children: [
20880
20883
  n.length > 1 ? /* @__PURE__ */ G("div", {
20881
- onClick: g(2),
20884
+ onClick: x(2),
20882
20885
  children: [/* @__PURE__ */ W("div", { children: "Audio" }), /* @__PURE__ */ W("div", { children: /* @__PURE__ */ W(go, {}) })]
20883
20886
  }) : null,
20887
+ y > 1 ? /* @__PURE__ */ G("div", {
20888
+ onClick: x(3),
20889
+ children: [/* @__PURE__ */ W("div", { children: "Quality" }), /* @__PURE__ */ G("div", { children: [b ? /* @__PURE__ */ W("span", {
20890
+ className: "secondary",
20891
+ children: b
20892
+ }) : null, /* @__PURE__ */ W(go, {})] })]
20893
+ }) : null,
20884
20894
  /* @__PURE__ */ G("div", {
20885
- onClick: g(1),
20895
+ onClick: x(1),
20886
20896
  children: [/* @__PURE__ */ W("div", { children: "Playback speed" }), /* @__PURE__ */ G("div", { children: [/* @__PURE__ */ W("span", {
20887
20897
  className: "secondary",
20888
20898
  children: t === 1 ? "(Default)" : t.toFixed(2)
20889
20899
  }), /* @__PURE__ */ W(go, {})] })]
20890
20900
  }),
20891
- a ? /* @__PURE__ */ G("div", {
20901
+ o ? /* @__PURE__ */ G("div", {
20892
20902
  role: "switch",
20893
- "aria-checked": a.available && a.enabled,
20894
- "aria-disabled": !a.available,
20895
- className: a.available ? void 0 : "no-hover unavailable",
20903
+ "aria-checked": o.available && o.enabled,
20904
+ "aria-disabled": !o.available,
20905
+ className: o.available ? void 0 : "no-hover unavailable",
20896
20906
  onClick: () => {
20897
- a.available && a.setEnabled(!a.enabled);
20907
+ o.available && o.setEnabled(!o.enabled);
20898
20908
  },
20899
- children: [/* @__PURE__ */ W("div", { children: "Normalize volume" }), /* @__PURE__ */ W("div", { children: a.available ? /* @__PURE__ */ W("span", { className: a.enabled ? "switch on" : "switch" }) : /* @__PURE__ */ W("span", {
20909
+ children: [/* @__PURE__ */ W("div", { children: "Normalize volume" }), /* @__PURE__ */ W("div", { children: o.available ? /* @__PURE__ */ W("span", { className: o.enabled ? "switch on" : "switch" }) : /* @__PURE__ */ W("span", {
20900
20910
  className: "secondary",
20901
20911
  children: "Unavailable"
20902
20912
  }) })]
20903
20913
  }) : null
20904
20914
  ]
20905
20915
  }),
20906
- c && o === 1 && /* @__PURE__ */ G("div", {
20916
+ l && s === 1 && /* @__PURE__ */ G("div", {
20907
20917
  className: "popover playback-rate",
20908
20918
  children: [
20909
20919
  /* @__PURE__ */ G("div", {
20910
20920
  className: "back",
20911
- onClick: g(0),
20921
+ onClick: x(0),
20912
20922
  children: [/* @__PURE__ */ W(ho, {}), /* @__PURE__ */ W("span", { children: "Playback speed" })]
20913
20923
  }),
20914
20924
  /* @__PURE__ */ W("div", {
@@ -20919,33 +20929,43 @@ ${$b}
20919
20929
  className: "options no-hover",
20920
20930
  children: [
20921
20931
  /* @__PURE__ */ W("div", {
20922
- onClick: () => m(.5),
20932
+ onClick: () => h(.5),
20923
20933
  children: "0.5x"
20924
20934
  }),
20925
20935
  /* @__PURE__ */ W("div", {
20926
- onClick: () => m(1),
20936
+ onClick: () => h(1),
20927
20937
  children: "1x"
20928
20938
  }),
20929
20939
  /* @__PURE__ */ W("div", {
20930
- onClick: () => m(1.5),
20940
+ onClick: () => h(1.5),
20931
20941
  children: "1.5x"
20932
20942
  }),
20933
20943
  /* @__PURE__ */ W("div", {
20934
- onClick: () => m(2),
20944
+ onClick: () => h(2),
20935
20945
  children: "2x"
20936
20946
  })
20937
20947
  ]
20938
20948
  })
20939
20949
  ]
20940
20950
  }),
20941
- c && o === 2 && /* @__PURE__ */ W(ex, {
20951
+ l && s === 2 && /* @__PURE__ */ W(ex, {
20942
20952
  title: "Audio",
20943
20953
  tracks: n,
20944
20954
  selected: r,
20945
- onSelect: h,
20946
- onBack: g(0),
20947
- pending: d,
20948
- failed: f
20955
+ onSelect: g,
20956
+ onBack: x(0),
20957
+ pending: f,
20958
+ failed: p
20959
+ }),
20960
+ l && s === 3 && a && /* @__PURE__ */ W(ex, {
20961
+ title: "Quality",
20962
+ tracks: a.options,
20963
+ selected: a.selectedId ?? void 0,
20964
+ onSelect: _,
20965
+ onBack: x(0),
20966
+ offLabel: a.offLabel,
20967
+ pending: f,
20968
+ failed: p
20949
20969
  })
20950
20970
  ]
20951
20971
  });
@@ -21388,6 +21408,7 @@ ${$b}
21388
21408
  e = Date.now() + Sx;
21389
21409
  };
21390
21410
  }, [D]), (0, v.useEffect)(() => () => clearInterval(y.current), []), /* @__PURE__ */ G("div", {
21411
+ className: "control-bar",
21391
21412
  css: Cx,
21392
21413
  style: { ...c ? {
21393
21414
  opacity: "0",
@@ -21718,47 +21739,51 @@ ${$b}
21718
21739
  let s = Y(), c = Y((e) => e.hideUI), l = Y((e) => e.setHideUI), u = Y((e) => e.hasMedia), d = Y((e) => e.passThroughPictureInPicture), f = (0, v.useRef)(void 0), p = (0, v.useRef)("mouse"), m = (0, v.useRef)({
21719
21740
  x: -1,
21720
21741
  y: -1
21721
- }), h = (0, v.useRef)(null);
21742
+ }), h = (0, v.useRef)(!1), g = (0, v.useRef)(null);
21722
21743
  (0, v.useEffect)(() => () => clearTimeout(f.current), []), (0, v.useEffect)(() => {
21723
21744
  u || (clearTimeout(f.current), l(!1));
21724
21745
  }, [u, l]);
21725
- let g = () => {
21746
+ let _ = () => {
21726
21747
  u && l(!0);
21727
- }, _ = (t) => {
21728
- h.current = t, typeof e == "function" ? e(t) : e && (e.current = t);
21729
- }, y = () => {
21748
+ }, y = (t) => {
21749
+ g.current = t, typeof e == "function" ? e(t) : e && (e.current = t);
21750
+ }, b = () => {
21730
21751
  if (s.passThroughPictureInPicture?.armed) return !0;
21731
21752
  if (p.current !== "mouse") return !1;
21732
21753
  let { x: e, y: t } = m.current;
21733
- if (!h.current || e < 0) return !1;
21754
+ if (!g.current || e < 0) return !1;
21734
21755
  let n = document.elementFromPoint(e, t);
21735
- return !!n && h.current.contains(n) && !n.closest(".video");
21736
- }, b = () => {
21737
- if (y()) {
21738
- f.current = setTimeout(b, Px);
21756
+ return !n || !g.current.contains(n) || n.closest(".video") ? !1 : !n.closest(".control-bar") || h.current || !!g.current.querySelector(".control-bar [aria-expanded=\"true\"]");
21757
+ }, x = () => {
21758
+ if (b()) {
21759
+ f.current = setTimeout(x, Px);
21739
21760
  return;
21740
21761
  }
21741
- g();
21742
- }, x = () => {
21743
- l(!1), clearTimeout(f.current), f.current = setTimeout(b, Px);
21744
- };
21762
+ _();
21763
+ }, S = () => {
21764
+ l(!1), clearTimeout(f.current), f.current = setTimeout(x, Px);
21765
+ }, C = (e) => {
21766
+ p.current = e.pointerType, e.pointerType === "mouse" && (m.current = {
21767
+ x: e.clientX,
21768
+ y: e.clientY
21769
+ }, S());
21770
+ }, w = (e) => {
21771
+ p.current = e.pointerType, h.current = !0;
21772
+ }, T = () => {
21773
+ h.current = !1;
21774
+ }, E = (0, v.useRef)(!0);
21745
21775
  return /* @__PURE__ */ G("div", {
21746
21776
  css: Lx,
21747
- ref: _,
21748
- onPointerMove: (e) => {
21749
- p.current = e.pointerType, e.pointerType === "mouse" && (m.current = {
21750
- x: e.clientX,
21751
- y: e.clientY
21752
- }, x());
21753
- },
21754
- onPointerDown: (e) => {
21755
- p.current = e.pointerType;
21756
- },
21777
+ ref: y,
21778
+ onPointerMove: C,
21779
+ onPointerDown: w,
21780
+ onPointerUp: T,
21781
+ onPointerCancel: T,
21757
21782
  onMouseOut: (e) => {
21758
21783
  let t = e.relatedTarget;
21759
21784
  if (t && e.currentTarget.contains(t)) return;
21760
21785
  let { left: n, right: r, top: i, bottom: a } = e.currentTarget.getBoundingClientRect();
21761
- e.clientX >= n && e.clientX < r && e.clientY >= i && e.clientY < a || (clearTimeout(f.current), g());
21786
+ e.clientX >= n && e.clientX < r && e.clientY >= i && e.clientY < a || (clearTimeout(f.current), _());
21762
21787
  },
21763
21788
  className: c ? "hide" : "",
21764
21789
  tabIndex: a === "player" ? -1 : void 0,
@@ -21775,23 +21800,27 @@ ${$b}
21775
21800
  }, e)),
21776
21801
  i === !1 ? null : /* @__PURE__ */ W(wx, {
21777
21802
  shortcuts: a,
21778
- root: h
21803
+ root: g
21779
21804
  }),
21780
21805
  /* @__PURE__ */ W(Dx, {}),
21781
21806
  /* @__PURE__ */ W(Nx, {}),
21782
- d?.armed && h.current ? /* @__PURE__ */ W("div", {
21807
+ d?.armed && g.current ? /* @__PURE__ */ W("div", {
21783
21808
  className: "pass-through",
21784
- style: { clipPath: Ix(d.armed, h.current.getBoundingClientRect()) },
21809
+ style: { clipPath: Ix(d.armed, g.current.getBoundingClientRect()) },
21785
21810
  onPointerOver: d.disarm
21786
21811
  }) : null,
21787
21812
  /* @__PURE__ */ G("div", {
21788
21813
  className: "video",
21789
- onClick: () => {
21814
+ onClick: (e) => {
21790
21815
  if (p.current === "mouse") {
21791
- s.togglePaused();
21816
+ if (e.detail > 1) return;
21817
+ E.current = s.paused, s.togglePaused();
21792
21818
  return;
21793
21819
  }
21794
- c ? x() : (clearTimeout(f.current), g());
21820
+ c ? S() : (clearTimeout(f.current), _());
21821
+ },
21822
+ onDoubleClick: () => {
21823
+ p.current === "mouse" && (E.current ? s.pause() : s.play().catch(() => {}), s.toggleFullscreen());
21795
21824
  },
21796
21825
  children: [t ? /* @__PURE__ */ W("video", {
21797
21826
  ref: t,
@@ -21882,7 +21911,7 @@ ${$b}
21882
21911
  V({ passThroughPictureInPicture: B });
21883
21912
  }, [V, B]);
21884
21913
  let te = n?.subtitles?.selection, ne = n?.audioTracks?.selection;
21885
- return (0, v.useEffect)(() => {
21914
+ (0, v.useEffect)(() => {
21886
21915
  te && V({
21887
21916
  subtitleTracks: te.options.map(({ id: e, label: t, disabled: n }) => ({
21888
21917
  id: e,
@@ -21903,7 +21932,11 @@ ${$b}
21903
21932
  selectedAudioTrack: ne.selectedId ?? void 0,
21904
21933
  selectAudioTrack: (e) => ne.select(String(e))
21905
21934
  });
21906
- }, [V, ne]), (0, v.useEffect)(() => {
21935
+ }, [V, ne]);
21936
+ let re = n?.quality?.selection ?? null;
21937
+ return (0, v.useEffect)(() => {
21938
+ V({ quality: re });
21939
+ }, [V, re]), (0, v.useEffect)(() => {
21907
21940
  a && V({ chapters: a });
21908
21941
  }, [V, a]), /* @__PURE__ */ W(zx, {
21909
21942
  ref: m,
@@ -44756,15 +44789,15 @@ var nK = () => window.location.origin + "/?magnet=%s", rK = () => typeof window
44756
44789
  }),
44757
44790
  /* @__PURE__ */ G("a", {
44758
44791
  className: "build",
44759
- href: "https://github.com/banou26/ripple/commit/556079d5777d6c7ea14820babe28313f4b2402eb",
44792
+ href: "https://github.com/banou26/ripple/commit/a812ac3ef4e115708bf4407c58d4962c60ccbaa3",
44760
44793
  target: "_blank",
44761
44794
  rel: "noreferrer",
44762
- ...mV("commit 556079d5777d6c7ea14820babe28313f4b2402eb"),
44795
+ ...mV("commit a812ac3ef4e115708bf4407c58d4962c60ccbaa3"),
44763
44796
  children: [
44764
44797
  "v",
44765
- "0.0.22",
44798
+ "0.0.23",
44766
44799
  " · ",
44767
- "556079d"
44800
+ "a812ac3"
44768
44801
  ]
44769
44802
  }),
44770
44803
  /* @__PURE__ */ G("div", {
@@ -44994,13 +45027,15 @@ var nK = () => window.location.origin + "/?magnet=%s", rK = () => typeof window
44994
45027
  /* @__PURE__ */ W("h1", { children: "Privacy" }),
44995
45028
  /* @__PURE__ */ W("div", {
44996
45029
  className: "updated",
44997
- children: "Last updated 5 July 2026"
45030
+ children: "Last updated 16 October 2026"
44998
45031
  }),
44999
45032
  /* @__PURE__ */ W("p", { children: "Ripple needs no account and runs no analytics. Used without an account, it keeps everything on your device and stores nothing about you on any server." }),
45000
45033
  /* @__PURE__ */ W("h2", { children: "What Ripple stores on your device" }),
45001
- /* @__PURE__ */ W("p", { children: "Your torrent list and all downloaded data live in your browser’s storage (OPFS and IndexedDB) on your device. Removing a torrent in Ripple also deletes its downloaded data. The files you download are never uploaded or reported anywhere by Ripple itself." }),
45002
- /* @__PURE__ */ W("h2", { children: "Torrent list sync when signed in" }),
45003
- /* @__PURE__ */ W("p", { children: "If you connect an FKN account, Ripple stores your torrent list (magnet links, save paths, and when you added each torrent; never the downloaded files themselves) in that account’s FKN cloud storage, encrypted in transit, so your library follows you across devices. Without an account connected, nothing is synced and everything stays local. Disconnecting the account stops the sync, and removing torrents while signed in also removes them from the synced list." }),
45034
+ /* @__PURE__ */ W("p", { children: "Your torrent list, each torrent’s resume data and thumbnail, and your settings (such as speed limits and how the list is sorted) live in your browser’s storage (IndexedDB and local storage). Downloaded files live in your browser’s storage (OPFS). If you choose a folder on your disk, finished downloads are saved there. Once you sign in, Ripple also remembers which FKN account your list belongs to." }),
45035
+ /* @__PURE__ */ W("p", { children: "Ripple never stores your files on FKN or on any server. Like any BitTorrent app, while a torrent is active it shares the pieces it has with the other peers of that torrent." }),
45036
+ /* @__PURE__ */ W("h2", { children: "Library sync when signed in" }),
45037
+ /* @__PURE__ */ W("p", { children: "If you connect an FKN account, Ripple keeps a copy of your library in that account’s FKN cloud storage, so it follows you across devices. For each torrent that is its magnet link, name, size and file list, where it is saved, when you added it, and its totals (data downloaded and uploaded, time active and seeding). It also keeps the torrents you removed and when, so other devices remove them too, and one thumbnail picture per torrent. It never syncs the downloaded files themselves." }),
45038
+ /* @__PURE__ */ W("p", { children: "All of it is encrypted in your browser before it is uploaded, so FKN stores it without being able to read it. File names stay visible to FKN, and each thumbnail’s file name is its torrent’s info hash. Without an account connected, nothing is synced." }),
45004
45039
  /* @__PURE__ */ W("h2", { children: "The relay and your IP address" }),
45005
45040
  /* @__PURE__ */ G("p", { children: [
45006
45041
  "Peer connections are tunneled through the FKN relay rather than opened directly, so the other peers in a swarm see the relay’s IP address, not yours. The relay forwards traffic without storing it. Usage metering for the free and premium tiers is anonymized with daily-rotating keys and purged within about a day, so past transfers cannot be mapped back to a person, by us or anyone else. The full details are in the",
@@ -45014,8 +45049,9 @@ var nK = () => window.location.origin + "/?magnet=%s", rK = () => typeof window
45014
45049
  "."
45015
45050
  ] }),
45016
45051
  /* @__PURE__ */ W("h2", { children: "Your control" }),
45052
+ /* @__PURE__ */ W("p", { children: "Removing a torrent deletes its data from your browser’s storage. Files in a folder on your disk, and the files you made a torrent from, stay where they are. Clearing this site’s data in your browser removes everything Ripple stored in your browser." }),
45017
45053
  /* @__PURE__ */ G("p", { children: [
45018
- "Removing a torrent deletes its data from your device. Clearing this site’s data in your browser removes everything Ripple has stored on your device. Ripple itself has no account to delete; if you use an FKN account for premium or sync, the account and its synced torrent list are managed at",
45054
+ "Ripple itself has no account to delete. Disconnecting your FKN account stops the sync. Removing a torrent also removes it from the synced list, but its synced thumbnail stays in your account. Deleting your FKN account at",
45019
45055
  " ",
45020
45056
  /* @__PURE__ */ W("a", {
45021
45057
  href: "https://fkn.app",
@@ -45023,7 +45059,8 @@ var nK = () => window.location.origin + "/?magnet=%s", rK = () => typeof window
45023
45059
  rel: "noreferrer noopener",
45024
45060
  children: "fkn.app"
45025
45061
  }),
45026
- "."
45062
+ " ",
45063
+ "deletes everything Ripple stored in it."
45027
45064
  ] }),
45028
45065
  /* @__PURE__ */ W("h2", { children: "Contact" }),
45029
45066
  /* @__PURE__ */ G("p", { children: [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ripple",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,6 +10,8 @@
10
10
  "fkn",
11
11
  "fkn-type:app"
12
12
  ],
13
+ "license": "MIT",
14
+ "description": "Download and stream torrents in your browser",
13
15
  "fkn": {
14
16
  "app": "fkn:app:1c7clnsv53zt7dr7q7rcbt455hxauykaxl4gibkuoao24dfmk2eqq",
15
17
  "signed": true,
package/sw.js CHANGED
@@ -8,7 +8,7 @@ const live = new Map()
8
8
  /* Stamped at build time. These are the dev defaults and must never reach production: a worker whose
9
9
  BUILD never changes is never reinstalled, so the update this feature announces would never fire. */
10
10
  const MODE = "cache" // @stamp:mode
11
- const BUILD = "86a8238a44ce2a21" // @stamp:build
11
+ const BUILD = "415e9074236b44ec" // @stamp:build
12
12
  const MANIFEST = [["/assets/empty-BvRk9kiK.js","e3b0c44298fc1c14"],["/assets/libtorrent-DY7aMUNo.js","88bdeacc1dcddd4e"],["/assets/worker-DqSNLC3T.js","16e5afe2c31c4219"],["/build-B7te3F-u.js","47b056c564f078c5"],["/remote-DvdN47iT.js","b0649250a9e3a079"],["/volume-normalizer-worklet.js","b8444040de06692c"]] // @stamp:manifest
13
13
 
14
14
  const CACHE = 'ripple-assets-' + BUILD