mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c4ccff50 | ||
|
|
a9ffddf679 | ||
|
|
bc085ad3be | ||
|
|
a05e0c77fd | ||
|
|
27e45f56b5 | ||
|
|
069fcb1b7f | ||
|
|
54a6e05285 | ||
|
|
9a44112c88 | ||
|
|
836509e2f0 | ||
|
|
b51a8010d0 | ||
|
|
83184bd32f | ||
|
|
40f87167b9 | ||
|
|
84e3da0a87 | ||
|
|
1e388757ff | ||
|
|
dda977b91a | ||
|
|
61750bbbca | ||
|
|
b3b85b6323 | ||
|
|
6a0692c3e3 | ||
|
|
ddd8a393f4 | ||
|
|
0ad6d53ebb | ||
|
|
c1da7dbb39 | ||
|
|
4d0731fe6e | ||
|
|
614847c99b | ||
|
|
df6878aef6 | ||
|
|
059112eab9 | ||
|
|
58f564d1b2 | ||
|
|
cb67c55753 | ||
|
|
eb989bee89 | ||
|
|
ebcb402569 | ||
|
|
7e63771651 | ||
|
|
1238de1d71 | ||
|
|
b3b0ca7979 | ||
|
|
0153965fdf | ||
|
|
65d7950932 | ||
|
|
8ea130fb22 | ||
|
|
93493815fb | ||
|
|
3b6aef5dfd | ||
|
|
61df14b2e4 | ||
|
|
96c9707113 | ||
|
|
457c80b1e7 | ||
|
|
35969b0fc6 | ||
|
|
9e3ca0b273 | ||
|
|
6f0dca0d62 | ||
|
|
e90f903b01 | ||
|
|
0de68cbd6b | ||
|
|
efa68321df | ||
|
|
90a3bf97fe | ||
|
|
c5668bfc3b | ||
|
|
ab1eaaa5a1 | ||
|
|
6fb9f91a34 | ||
|
|
32f652e7a8 | ||
|
|
7c1675805e | ||
|
|
67b8caa623 | ||
|
|
1d0100619e | ||
|
|
b7ebed9843 | ||
|
|
237724b8ac | ||
|
|
ac0777534e | ||
|
|
4cbcfcfbf4 | ||
|
|
a593ea0f5b | ||
|
|
52553165d6 | ||
|
|
ddd6e40696 | ||
|
|
2d48f89f45 | ||
|
|
6e5c73f731 | ||
|
|
c56ffdfee6 | ||
|
|
0557014986 | ||
|
|
6c6641c961 | ||
|
|
77936e7656 | ||
|
|
69ca1f1d58 | ||
|
|
f9f9c8149c | ||
|
|
9629ef01ed | ||
|
|
fa738b9cb7 | ||
|
|
7bdf93cb63 | ||
|
|
e58c16ab83 | ||
|
|
dbe4563bb7 | ||
|
|
73e5a6bc83 | ||
|
|
109f5e0746 | ||
|
|
c5b30921ae | ||
|
|
c9ebc80ab4 | ||
|
|
c488f3fa16 | ||
|
|
1a6371fee3 | ||
|
|
72f3e4f8fd | ||
|
|
adf6bd56cd | ||
|
|
d89b38006a | ||
|
|
9bf283c2d4 | ||
|
|
02de05e31a | ||
|
|
447aea3d4b | ||
|
|
b617648136 | ||
|
|
8a9e325c22 | ||
|
|
d511757473 | ||
|
|
0af08f60b0 | ||
|
|
ce8a2aab87 | ||
|
|
fbd6fa4121 | ||
|
|
d6a34571a9 | ||
|
|
e85831bbf6 | ||
|
|
ff4fd71cbe | ||
|
|
aaf9066666 | ||
|
|
72db5396b4 | ||
|
|
16d4578ead | ||
|
|
14589b571c | ||
|
|
f482124411 | ||
|
|
f61291f75b | ||
|
|
42bfde21d5 | ||
|
|
8e0a5e0034 | ||
|
|
b031e98c4a | ||
|
|
5bf3112436 | ||
|
|
3b4bb7f0df | ||
|
|
5795a7e7d4 | ||
|
|
b66d6e1080 | ||
|
|
8c730e772b | ||
|
|
8e42816ec5 | ||
|
|
e7d895f123 | ||
|
|
04127bc22a | ||
|
|
996647f23d | ||
|
|
b52076cc26 | ||
|
|
940d9e66f7 | ||
|
|
5211897412 | ||
|
|
4456719bc9 | ||
|
|
96e1679618 | ||
|
|
82bd833594 | ||
|
|
9b11fe720c | ||
|
|
6ecd625fce | ||
|
|
9342c967ba | ||
|
|
9927b7e4ec | ||
|
|
2542e97398 | ||
|
|
5d0003c13d | ||
|
|
713cc909c7 | ||
|
|
02a1b5121d | ||
|
|
c94eb928e1 | ||
|
|
c384fc515f | ||
|
|
c5f67bc31e | ||
|
|
f0f1a7cf8f | ||
|
|
8a10a7cfc8 | ||
|
|
2ae9f01cb5 | ||
|
|
48a1f24ac9 | ||
|
|
b29ee486f0 | ||
|
|
9229f0fe80 | ||
|
|
e0da106442 | ||
|
|
532ce8e2d2 | ||
|
|
86928b8e8c | ||
|
|
b52e767f86 | ||
|
|
1be2c124b7 | ||
|
|
0d56082c54 | ||
|
|
d3f42b6eb3 | ||
|
|
2150ac0f0b | ||
|
|
e4ae6fadef | ||
|
|
eb9b50b572 | ||
|
|
8188b9f71a | ||
|
|
52ecb76ac9 | ||
|
|
80263bdba5 | ||
|
|
57496969d7 | ||
|
|
d9afdabe6c | ||
|
|
621465c538 | ||
|
|
1450e09697 | ||
|
|
dd47fbed93 | ||
|
|
548e0fd3c0 | ||
|
|
ed6b69b2e0 | ||
|
|
ee4ceb95b7 | ||
|
|
7742e3968c | ||
|
|
2442be4e77 | ||
|
|
3593fdd0ef | ||
|
|
873d1065e4 | ||
|
|
0d622bdb8d | ||
|
|
fc0bae85af | ||
|
|
db43e3634b | ||
|
|
c88bc404b6 | ||
|
|
d271bd335c | ||
|
|
fea5835a62 | ||
|
|
985acf6456 | ||
|
|
3e7eab1c92 | ||
|
|
bfee3b7a2a | ||
|
|
88eaff3107 | ||
|
|
33f29380f5 | ||
|
|
ef2fad74f8 | ||
|
|
167691d256 | ||
|
|
e3ee938f8d | ||
|
|
0569755901 | ||
|
|
a84f635539 | ||
|
|
c6891ca57c | ||
|
|
9f3f42181c | ||
|
|
0e0249ff91 | ||
|
|
fe0b89697f | ||
|
|
1e77d617ec | ||
|
|
56b478454c | ||
|
|
867e111a48 | ||
|
|
1256610429 | ||
|
|
4107796639 | ||
|
|
c84dc1e812 | ||
|
|
02ee898923 | ||
|
|
bd1c68d202 | ||
|
|
886b02a5ff | ||
|
|
0e5b871488 | ||
|
|
2ed1cc50db | ||
|
|
ad5f8958c5 | ||
|
|
9014371263 | ||
|
|
7350f43ca7 | ||
|
|
ba9bba517a | ||
|
|
811e1e30e1 | ||
|
|
7812034f0d | ||
|
|
b51b692f31 | ||
|
|
3ca66c7288 | ||
|
|
fe0245e30c | ||
|
|
2a26ef58f6 | ||
|
|
c4289161e1 | ||
|
|
0373859ad3 |
No files matched your search
@@ -0,0 +1,84 @@
|
|||||||
|
# Bitvid — AI Agent Guide
|
||||||
|
|
||||||
|
This guide tells AI agents how to keep Bitvid aligned with the current product direction. Follow it whenever you touch code, content, or documentation inside this repository.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Release Channels: Main vs. Unstable
|
||||||
|
|
||||||
|
* **Main** is the production track. Anything merged here must preserve today’s UX and magnet safety guarantees. Rollbacks should be painless: keep commits atomic, avoid destructive migrations, and leave feature flags in a known-good default (`false` unless product explicitly flips them).
|
||||||
|
* **Unstable** is our experimentation lane. Gate risky behavior behind feature flags defined in `js/constants.js` and document the toggle/rollback plan in PR descriptions.
|
||||||
|
* **Emergency response:** If a change regresses URL-first playback or breaks magnet parsing, revert immediately and annotate the AGENTS.md changelog with remediation tips so future agents do not repeat the mistake.
|
||||||
|
|
||||||
|
## 2. Mission: URL‑First Playback with WebTorrent Fallback
|
||||||
|
|
||||||
|
* **Goal:** Always deliver smooth playback while keeping hosting costs low.
|
||||||
|
* **Primary transport:** A hosted video URL (MP4/WebM/HLS/DASH) that the `<video>` element can stream directly.
|
||||||
|
* **Fallback transport:** A WebTorrent magnet that includes browser‑safe trackers plus optional HTTP hints.
|
||||||
|
* **Runtime behavior:** Call `playVideoWithFallback({ url, magnet })` (see `js/playbackUtils.js`). It probes the URL first; only when that fails should WebTorrent start.
|
||||||
|
* **Content contract:** Bitvid posts (Nostr kind `30078`) must include a `title` and at least one of `url` or `magnet`. Prefer to publish both along with optional `thumbnail`, `description`, and `mode` fields. When mirroring to NIP‑94 (`kind 1063`), copy the hosted URL and (optionally) the magnet so other clients can discover the same asset.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Magnet Handling — Do’s & Don’ts
|
||||||
|
|
||||||
|
* **Use the helpers every time:** Always run inbound values through `safeDecodeMagnet()` before playback or normalization, and call `normalizeAndAugmentMagnet()` (see `js/magnetUtils.js`) to append `ws=` / `xs=` hints without mutating hashes.
|
||||||
|
* **Keep magnets raw:** Persist the literal `magnet:?xt=urn:btih:...` string. Decode once, then feed that exact value to WebTorrent or storage. Never rely on re-encoding helpers that might alter casing.
|
||||||
|
* **Never call `new URL()` on magnets:** URL constructors/`URLSearchParams` percent-encode the `xt` payload and corrupt legacy hashes. Manipulate magnets with the helper utilities or string functions that respect the raw hash.
|
||||||
|
* **HTTP hints:** Encourage HTTPS `ws=` web seeds (file roots only) and HTTPS `xs=` pointers to `.torrent` files so fallback peers warm quickly.
|
||||||
|
* **Tracker policy:** Browser code must ship WSS trackers only. Pull the canonical list from `js/constants.js`; do not add UDP or plaintext HTTP trackers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Upload Modal Troubleshooting
|
||||||
|
|
||||||
|
1. **Required fields:** Validation must ensure a title exists and that either a hosted URL or a magnet (or both) is supplied. Optional `ws` / `xs` inputs should be appended only when a magnet is present.
|
||||||
|
2. **Magnet parsing errors:** When a user pastes encoded magnets, run `safeDecodeMagnet()` before normalizing. Show inline errors if decoding fails and confirm the raw `magnet:?xt=` string survives the round trip.
|
||||||
|
3. **URL-first promise:** After submission, confirm the resulting feed item carries `data-play-url` and `data-play-magnet` attributes so playback utilities can attempt the URL first.
|
||||||
|
4. **Mixed-content warnings:** If the page is served over HTTPS, reject `ws=` or `xs=` hints that begin with `http://` and show guidance to upgrade to HTTPS. Document the copy in README/UX strings so operators can align messaging.
|
||||||
|
5. **Telemetry hooks:** Keep existing analytics/logging untouched unless instructed. If you add new modal states, annotate them clearly in code comments.
|
||||||
|
6. **Modal regressions:** If validation or helper wiring breaks in Main, disable new feature flags and ship a revert PR immediately. Note the rollback steps in AGENTS.md for posterity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Manual QA Checklist
|
||||||
|
|
||||||
|
Run this script before shipping notable UI changes, especially around upload/playback flows. Automate when possible, but manual verification is required for releases.
|
||||||
|
|
||||||
|
1. **Smoke test the modal**
|
||||||
|
* Launch the site, open the Upload modal, and confirm required-field validation (title + one of URL/magnet).
|
||||||
|
* Submit with URL only, magnet only, and both; ensure success states are clear.
|
||||||
|
2. **URL-first playback**
|
||||||
|
* Publish a post with both URL and magnet. Load the card and verify the player fetches the hosted URL first (check network tab or logs).
|
||||||
|
* Temporarily block the URL (e.g., devtools throttling or offline). Confirm playback seamlessly falls back to the magnet.
|
||||||
|
3. **Magnet hygiene**
|
||||||
|
* Paste an encoded magnet; ensure `safeDecodeMagnet()` returns the raw value and the final string still includes the original `xt` hash.
|
||||||
|
* Confirm `normalizeAndAugmentMagnet()` appends `ws=`/`xs=` hints without corrupting parameters.
|
||||||
|
4. **P2P hints**
|
||||||
|
* Verify magnets authored through the modal include HTTPS `ws=` and optional `xs=` values when provided.
|
||||||
|
* Ensure tracker lists come from `js/constants.js` and remain WSS-only.
|
||||||
|
5. **Cross-browser sanity**
|
||||||
|
* Spot-check playback in Chromium and Firefox (desktop) to ensure no console errors related to CORS, Range requests, or tracker connections.
|
||||||
|
|
||||||
|
Document the run in PR descriptions so QA can cross-reference results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Additional Notes for Agents
|
||||||
|
|
||||||
|
* **Nostr interoperability:** Keep kind `30078` events as the source of truth and optionally mirror to kind `1063` so external clients see the hosted URL.
|
||||||
|
* **Probing:** Lightweight `HEAD`/`GET` requests should back `probeUrl()` so dead URLs can be hidden or flagged without blocking the UI.
|
||||||
|
* **Extensibility:** Future work (live streams, NIP-96 uploads, analytics) should preserve the URL-first strategy and magnet safety rules above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Content Schema v3 & Playback Rules
|
||||||
|
|
||||||
|
* **Event payloads:** New video notes serialize as version `3` with the JSON shape:
|
||||||
|
`{ "version": 3, "title": string, "url"?: string, "magnet"?: string, "thumbnail"?: string, "description"?: string, "mode": "live"|"dev", "isPrivate": boolean, "deleted": boolean, "videoRootId": string }`.
|
||||||
|
* **Validation:** Every note must include a non-empty `title` plus at least one playable source (`url` or `magnet`). URL-only and magnet-only posts are both valid. Legacy v2 magnet notes stay readable.
|
||||||
|
* **Upload UX:** The modal collects a hosted HTTPS URL and/or magnet. Enforce HTTPS for direct playback while keeping magnets optional when a URL is supplied.
|
||||||
|
* **Playback orchestration:** `playVideoWithFallback` probes and plays the HTTPS URL first, watches for stalls/errors, and then falls back to WebTorrent. When both sources exist, pass the hosted URL through to WebTorrent as a webseed hint.
|
||||||
|
* **Status messaging:** Update modal copy to reflect whether playback is direct or via P2P so regressions surface quickly during QA.
|
||||||
|
|
||||||
|
**End of AGENTS.md**
|
||||||
@@ -26,11 +26,25 @@
|
|||||||
1. **Visit the Site**: Navigate to the live instance of **bitvid** (e.g., `[https://bitvid.network](https://bitvid.network)`).
|
1. **Visit the Site**: Navigate to the live instance of **bitvid** (e.g., `[https://bitvid.network](https://bitvid.network)`).
|
||||||
2. **Login with Nostr**:
|
2. **Login with Nostr**:
|
||||||
- Use a compatible Nostr browser extension or manually input your public key.
|
- Use a compatible Nostr browser extension or manually input your public key.
|
||||||
3. **Upload Videos**:
|
|
||||||
- Provide a title, magnet link, and optional thumbnail or description.
|
### Post a video
|
||||||
- Toggle "Private" for encrypted listings.
|
|
||||||
4. **Stream Videos**:
|
The upload modal enforces **Title + (URL or Magnet)**. Hosted URLs are strongly recommended so the player can start instantly, and you may also add a magnet for resilience.
|
||||||
- Play videos directly in the browser using WebTorrent technology.
|
|
||||||
|
- **Title**: Required. Matches the in-app validation copy.
|
||||||
|
- **Hosted URL (recommended)**: Supply an HTTPS MP4/WebM/HLS/DASH asset. This is the primary playback path.
|
||||||
|
- **Magnet (optional but encouraged)**: Paste the literal `magnet:?xt=urn:btih:...` string. The form decodes it with `safeDecodeMagnet()` to prevent hash corruption. Never wrap magnets in `new URL()`—store them raw or decode then pass directly to the helpers.
|
||||||
|
- **Web seeds (`ws=`)**: HTTPS only. Point to a file root (e.g., `https://cdn.example.com/video/`). Mixed-content (`http://`) hints are rejected just like the modal message explains.
|
||||||
|
- **Additional sources (`xs=`)**: Recommend adding an HTTPS `.torrent` link so WebTorrent peers can bootstrap faster.
|
||||||
|
- **Trackers**: Bitvid’s browser client only connects to WSS trackers shipped in `js/constants.js`. Do not add UDP or plaintext HTTP trackers to published magnets.
|
||||||
|
- **Private toggle**: Encrypts the listing for invited viewers.
|
||||||
|
|
||||||
|
### How playback works
|
||||||
|
|
||||||
|
1. **URL-first**: `playVideoWithFallback({ url, magnet })` attempts the hosted URL immediately. Healthy URLs deliver the full experience without touching P2P resources.
|
||||||
|
2. **WebTorrent fallback**: If the URL probe fails or returns an error status, Bitvid falls back to WebTorrent using the raw magnet. The helpers append HTTPS `ws=`/`xs=` hints so peers seed quickly.
|
||||||
|
3. **Safety checks**: Magnets are decoded with `safeDecodeMagnet()` and normalized via `normalizeAndAugmentMagnet()` before reaching WebTorrent. Trackers remain WSS-only to satisfy browser constraints.
|
||||||
|
4. **Operator playbook**: If a deployment causes playback regressions, flip the relevant feature flags back to their default values in `js/constants.js` and redeploy. Capture the rollback steps in AGENTS.md and the PR description so the Main channel stays stable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -67,6 +81,10 @@ To run **bitvid** locally:
|
|||||||
|
|
||||||
- **`config.js`**:
|
- **`config.js`**:
|
||||||
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
- Toggle `isDevMode` for development (`true`) or production (`false`).
|
||||||
|
- **`js/constants.js`**:
|
||||||
|
- Source for browser-safe tracker lists and feature flags that govern WebTorrent behavior.
|
||||||
|
- **Magnet helpers**:
|
||||||
|
- Use `safeDecodeMagnet()` and `normalizeAndAugmentMagnet()` from `js/magnetUtils.js` to preserve hashes and add `ws=` / `xs=` hints safely.
|
||||||
|
|
||||||
### Adding Features
|
### Adding Features
|
||||||
|
|
||||||
@@ -101,6 +119,21 @@ To run **bitvid** locally:
|
|||||||
- Follow the [MIT License](https://opensource.org/licenses/MIT).
|
- Follow the [MIT License](https://opensource.org/licenses/MIT).
|
||||||
- Use clear, concise commit messages.
|
- Use clear, concise commit messages.
|
||||||
- Respect the existing coding style and architecture.
|
- Respect the existing coding style and architecture.
|
||||||
|
- Run the manual QA script (see below) and note results in PR descriptions for changes that affect upload or playback.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Use the manual QA checklist before releases or when altering upload/playback flows:
|
||||||
|
|
||||||
|
1. Open the Upload modal, confirm validation (title plus URL or magnet), and test submissions for URL-only, magnet-only, and combined entries.
|
||||||
|
2. Publish a post with both URL and magnet, verify the player streams the hosted URL, then simulate a URL failure and confirm WebTorrent playback.
|
||||||
|
3. Paste encoded magnets to ensure `safeDecodeMagnet()` returns the raw string and `normalizeAndAugmentMagnet()` adds `ws=` / `xs=` hints without corruption.
|
||||||
|
4. Confirm magnets include HTTPS `ws=` / optional `xs=` hints and use the WSS tracker list from `js/constants.js`.
|
||||||
|
5. Spot-check Chromium and Firefox for console warnings (CORS, Range requests, tracker connectivity).
|
||||||
|
|
||||||
|
See [`docs/qa.md`](docs/qa.md) for the copy/paste-friendly checklist we share with QA.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 263 B |
@@ -34,6 +34,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title></title>
|
<title></title>
|
||||||
|
<script src="./js/tracking.js" defer></script>
|
||||||
<script type="module" crossorigin>
|
<script type="module" crossorigin>
|
||||||
var pv = Object.defineProperty;
|
var pv = Object.defineProperty;
|
||||||
var gv = (e, t, n) =>
|
var gv = (e, t, n) =>
|
||||||
|
|||||||
+186
-180
@@ -1,6 +1,11 @@
|
|||||||
|
<!--
|
||||||
|
Sidebar fades in once when hydrated on the client. js/index.js removes the
|
||||||
|
helper classes after the initial animation to avoid flashes each time we
|
||||||
|
re-render navigation links.
|
||||||
|
-->
|
||||||
<aside
|
<aside
|
||||||
id="sidebar"
|
id="sidebar"
|
||||||
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto"
|
class="bg-gray-900 text-white transition-transform duration-300 ease-in-out fixed top-0 left-0 w-64 h-screen overflow-y-auto fade-in fade-in-delay-100"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col h-full">
|
<div class="flex flex-col h-full">
|
||||||
<!-- Top Navigation Links -->
|
<!-- Top Navigation Links -->
|
||||||
@@ -45,178 +50,182 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile Dropdown Button for Footer Links -->
|
<!-- Footer Links Toggle -->
|
||||||
<!-- Added p-4 around the button for spacing, and styling consistent with top links -->
|
<div class="md:mt-auto">
|
||||||
<div class="md:hidden p-4">
|
|
||||||
<button
|
|
||||||
id="footerDropdownButton"
|
|
||||||
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
|
|
||||||
>
|
|
||||||
<!-- Arrow on the left side -->
|
|
||||||
<svg
|
|
||||||
id="footerDropdownIcon"
|
|
||||||
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
fill="none"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
stroke-linecap="round"
|
|
||||||
stroke-linejoin="round"
|
|
||||||
stroke-width="2"
|
|
||||||
d="M19 9l-7 7-7-7"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
<span id="footerDropdownText">More</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer Links Container -->
|
|
||||||
<!-- On mobile: hidden by default; on desktop: visible and pushed to the bottom -->
|
|
||||||
<div id="footerLinksContainer" class="hidden md:block md:mt-auto">
|
|
||||||
<hr class="border-gray-700" />
|
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<nav class="space-y-2 text-sm opacity-70">
|
<button
|
||||||
<a
|
id="footerDropdownButton"
|
||||||
href="#view=about"
|
type="button"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
class="flex items-center w-full py-2 px-4 bg-gray-800 hover:bg-gray-700 text-sm font-semibold text-gray-100 rounded shadow-md focus:outline-none transition-colors duration-200"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="footerLinksContainer"
|
||||||
|
>
|
||||||
|
<!-- Arrow on the left side -->
|
||||||
|
<svg
|
||||||
|
id="footerDropdownIcon"
|
||||||
|
class="w-4 h-4 mr-2 transform transition-transform duration-300"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
>
|
>
|
||||||
<img
|
<path
|
||||||
src="assets/svg/about-icon.svg"
|
stroke-linecap="round"
|
||||||
alt="About"
|
stroke-linejoin="round"
|
||||||
class="w-5 h-5 mr-3"
|
stroke-width="2"
|
||||||
|
d="M19 9l-7 7-7-7"
|
||||||
/>
|
/>
|
||||||
<span class="sidebar-text">About</span>
|
|
||||||
</a>
|
</svg>
|
||||||
<a
|
<span id="footerDropdownText">More</span>
|
||||||
href="#view=community-guidelines"
|
</button>
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
</div>
|
||||||
>
|
|
||||||
<img
|
<!-- Footer Links Container -->
|
||||||
src="assets/svg/guidelines-icon.svg"
|
<div id="footerLinksContainer" class="hidden">
|
||||||
alt="Guidelines"
|
<hr class="border-gray-700" />
|
||||||
class="w-5 h-5 mr-3"
|
<div class="p-4">
|
||||||
/>
|
<nav class="space-y-2 text-sm opacity-70">
|
||||||
<span class="sidebar-text">Guidelines</span>
|
<a
|
||||||
</a>
|
href="#view=about"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="#view=getting-started"
|
>
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
<img
|
||||||
>
|
src="assets/svg/about-icon.svg"
|
||||||
<img
|
alt="About"
|
||||||
src="assets/svg/getting-started-icon.svg"
|
class="w-5 h-5 mr-3"
|
||||||
alt="Getting Started"
|
/>
|
||||||
class="w-5 h-5 mr-3"
|
<span class="sidebar-text">About</span>
|
||||||
/>
|
</a>
|
||||||
<span class="sidebar-text">Getting Started</span>
|
<a
|
||||||
</a>
|
href="#view=community-guidelines"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="#view=roadmap"
|
>
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
<img
|
||||||
>
|
src="assets/svg/guidelines-icon.svg"
|
||||||
<img
|
alt="Guidelines"
|
||||||
src="assets/svg/roadmap-icon.svg"
|
class="w-5 h-5 mr-3"
|
||||||
alt="Roadmap"
|
/>
|
||||||
class="w-5 h-5 mr-3"
|
<span class="sidebar-text">Guidelines</span>
|
||||||
/>
|
</a>
|
||||||
<span class="sidebar-text">Roadmap</span>
|
<a
|
||||||
</a>
|
href="#view=getting-started"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="#view=blog"
|
>
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
<img
|
||||||
>
|
src="assets/svg/getting-started-icon.svg"
|
||||||
<img
|
alt="Getting Started"
|
||||||
src="assets/svg/blog-icon.svg"
|
class="w-5 h-5 mr-3"
|
||||||
alt="Blog"
|
/>
|
||||||
class="w-5 h-5 mr-3"
|
<span class="sidebar-text">Getting Started</span>
|
||||||
/>
|
</a>
|
||||||
<span class="sidebar-text">Blog</span>
|
<a
|
||||||
</a>
|
href="#view=roadmap"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
>
|
||||||
target="_blank"
|
<img
|
||||||
rel="noopener noreferrer"
|
src="assets/svg/roadmap-icon.svg"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
alt="Roadmap"
|
||||||
>
|
class="w-5 h-5 mr-3"
|
||||||
<img
|
/>
|
||||||
src="assets/svg/nostr-icon.svg"
|
<span class="sidebar-text">Roadmap</span>
|
||||||
alt="Nostr"
|
</a>
|
||||||
class="w-5 h-5 mr-3"
|
<a
|
||||||
/>
|
href="#view=blog"
|
||||||
<span class="sidebar-text">Nostr</span>
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
</a>
|
>
|
||||||
<a
|
<img
|
||||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
src="assets/svg/blog-icon.svg"
|
||||||
target="_blank"
|
alt="Blog"
|
||||||
rel="noopener noreferrer"
|
class="w-5 h-5 mr-3"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
/>
|
||||||
>
|
<span class="sidebar-text">Blog</span>
|
||||||
<img
|
</a>
|
||||||
src="assets/svg/github-icon.svg"
|
<a
|
||||||
alt="GitHub"
|
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||||
class="w-5 h-5 mr-3"
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
<span class="sidebar-text">GitHub</span>
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
</a>
|
>
|
||||||
<a
|
<img
|
||||||
href="torrent/beacon.html"
|
src="assets/svg/nostr-icon.svg"
|
||||||
target="_blank"
|
alt="Nostr"
|
||||||
rel="noopener noreferrer"
|
class="w-5 h-5 mr-3"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
/>
|
||||||
>
|
<span class="sidebar-text">Nostr</span>
|
||||||
<img
|
</a>
|
||||||
src="assets/svg/beacon-icon.svg"
|
<a
|
||||||
alt="βeacon"
|
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||||
class="w-5 h-5 mr-3"
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
<span class="sidebar-text">βeacon</span>
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
</a>
|
>
|
||||||
<a
|
<img
|
||||||
href="https://beta.bitvid.network/"
|
src="assets/svg/github-icon.svg"
|
||||||
target="_blank"
|
alt="GitHub"
|
||||||
rel="noopener noreferrer"
|
class="w-5 h-5 mr-3"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
/>
|
||||||
>
|
<span class="sidebar-text">GitHub</span>
|
||||||
<img
|
</a>
|
||||||
src="assets/svg/beta-icon.svg"
|
<a
|
||||||
alt="Beta"
|
href="torrent/beacon.html"
|
||||||
class="w-5 h-5 mr-3"
|
target="_blank"
|
||||||
/>
|
rel="noopener noreferrer"
|
||||||
<span class="sidebar-text">Beta</span>
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
</a>
|
>
|
||||||
<a
|
<img
|
||||||
href="#view=links"
|
src="assets/svg/beacon-icon.svg"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
alt="βeacon"
|
||||||
>
|
class="w-5 h-5 mr-3"
|
||||||
<img
|
/>
|
||||||
src="assets/svg/links-icon.svg"
|
<span class="sidebar-text">βeacon</span>
|
||||||
alt="Links"
|
</a>
|
||||||
class="w-5 h-5 mr-3"
|
<a
|
||||||
/>
|
href="https://beta.bitvid.network/"
|
||||||
<span class="sidebar-text">Links</span>
|
target="_blank"
|
||||||
</a>
|
rel="noopener noreferrer"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="#view=ipns"
|
>
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
<img
|
||||||
>
|
src="assets/svg/beta-icon.svg"
|
||||||
<img
|
alt="Beta"
|
||||||
src="assets/svg/ipns-icon.svg"
|
class="w-5 h-5 mr-3"
|
||||||
alt="IPNS"
|
/>
|
||||||
class="w-5 h-5 mr-3"
|
<span class="sidebar-text">Beta</span>
|
||||||
/>
|
</a>
|
||||||
<span class="sidebar-text">IPNS</span>
|
<a
|
||||||
</a>
|
href="#view=links"
|
||||||
<a
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
href="https://bitvid.network/"
|
>
|
||||||
target="_blank"
|
<img
|
||||||
rel="noopener noreferrer"
|
src="assets/svg/links-icon.svg"
|
||||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
alt="Links"
|
||||||
>
|
class="w-5 h-5 mr-3"
|
||||||
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
/>
|
||||||
<span class="sidebar-text">DNS</span>
|
<span class="sidebar-text">Links</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
<a
|
||||||
|
href="#view=ipns"
|
||||||
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/ipns-icon.svg"
|
||||||
|
alt="IPNS"
|
||||||
|
class="w-5 h-5 mr-3"
|
||||||
|
/>
|
||||||
|
<span class="sidebar-text">IPNS</span>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://bitvid.network/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||||
|
>
|
||||||
|
<img src="assets/svg/dns-icon.svg" alt="DNS" class="w-5 h-5 mr-3" />
|
||||||
|
<span class="sidebar-text">DNS</span>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,18 +237,15 @@
|
|||||||
if (btn && footerLinks) {
|
if (btn && footerLinks) {
|
||||||
btn.addEventListener("click", function () {
|
btn.addEventListener("click", function () {
|
||||||
footerLinks.classList.toggle("hidden");
|
footerLinks.classList.toggle("hidden");
|
||||||
|
var isHidden = footerLinks.classList.contains("hidden");
|
||||||
|
btn.setAttribute("aria-expanded", String(!isHidden));
|
||||||
var icon = document.getElementById("footerDropdownIcon");
|
var icon = document.getElementById("footerDropdownIcon");
|
||||||
var textSpan = document.getElementById("footerDropdownText");
|
var textSpan = document.getElementById("footerDropdownText");
|
||||||
if (footerLinks.classList.contains("hidden")) {
|
if (textSpan) {
|
||||||
textSpan.textContent = "More";
|
textSpan.textContent = isHidden ? "More" : "Less";
|
||||||
if (icon) {
|
}
|
||||||
icon.classList.remove("rotate-180");
|
if (icon) {
|
||||||
}
|
icon.classList.toggle("rotate-180", !isHidden);
|
||||||
} else {
|
|
||||||
textSpan.textContent = "Less";
|
|
||||||
if (icon) {
|
|
||||||
icon.classList.add("rotate-180");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
>
|
>
|
||||||
<!-- Top Bar (similar to video-modal) -->
|
<!-- Top Bar (similar to video-modal) -->
|
||||||
<div
|
<div
|
||||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300 p-4 flex items-center justify-between"
|
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150 p-4 flex items-center justify-between"
|
||||||
>
|
>
|
||||||
<h2 class="text-xl font-bold text-white">Share a Video</h2>
|
<h2 class="text-xl font-bold text-white">Share a Video</h2>
|
||||||
<button
|
<button
|
||||||
@@ -52,6 +52,9 @@
|
|||||||
<!-- Form Content -->
|
<!-- Form Content -->
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<form id="uploadForm" class="space-y-4">
|
<form id="uploadForm" class="space-y-4">
|
||||||
|
<p class="text-sm text-gray-400">
|
||||||
|
Share a title plus either a hosted video URL or a magnet link. Providing both helps Bitvid fall back gracefully.
|
||||||
|
</p>
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="uploadTitle"
|
for="uploadTitle"
|
||||||
@@ -66,18 +69,57 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hosted video URL -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label
|
||||||
|
for="uploadUrl"
|
||||||
|
class="block text-sm font-medium text-gray-200"
|
||||||
|
>Hosted video URL (https)</label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="uploadUrl"
|
||||||
|
type="url"
|
||||||
|
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||||
|
placeholder="https://cdn.example.com/video.mp4"
|
||||||
|
/>
|
||||||
|
<small class="mt-1 block text-xs text-gray-400"
|
||||||
|
>Optional but preferred. We will play this first and fall back to
|
||||||
|
P2P.</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Optional: seed bootstraps for magnet -->
|
||||||
|
<div class="grid grid-cols-1 gap-3 mb-2 text-xs">
|
||||||
|
<input
|
||||||
|
id="uploadWs"
|
||||||
|
type="url"
|
||||||
|
class="w-full p-2 rounded"
|
||||||
|
placeholder="Web seed (ws=) e.g., https://cdn.example.com/video.mp4"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="uploadXs"
|
||||||
|
type="url"
|
||||||
|
class="w-full p-2 rounded"
|
||||||
|
placeholder=".torrent URL (xs=) e.g., https://cdn.example.com/video.torrent"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="uploadMagnet"
|
for="uploadMagnet"
|
||||||
class="block text-sm font-medium text-gray-200"
|
class="block text-sm font-medium text-gray-200"
|
||||||
>Magnet Link</label
|
>Magnet link (optional if URL provided)</label
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="uploadMagnet"
|
id="uploadMagnet"
|
||||||
required
|
placeholder="magnet:?xt=urn:btih:..."
|
||||||
class="mt-1 block w-full rounded-md border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
class="mt-1 block w-full rounded-md border border-gray-700 bg-gray-800 text-gray-100 focus:border-blue-500 focus:ring-blue-500"
|
||||||
/>
|
/>
|
||||||
|
<p class="mt-1 text-xs text-gray-400">
|
||||||
|
Provide a hosted URL for instant playback. Add a magnet for P2P
|
||||||
|
cost control.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<!-- Navigation bar - sliding at top -->
|
<!-- Navigation bar - sliding at top -->
|
||||||
<div
|
<div
|
||||||
id="modalNav"
|
id="modalNav"
|
||||||
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-300"
|
class="sticky top-0 z-60 bg-gradient-to-b from-black/80 to-transparent transition-transform duration-150"
|
||||||
>
|
>
|
||||||
<div class="flex items-center px-6 py-4">
|
<div class="flex items-center px-6 py-4">
|
||||||
<button
|
<button
|
||||||
@@ -51,6 +51,19 @@
|
|||||||
class="icon-image"
|
class="icon-image"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
<!-- Zap Button (circular) -->
|
||||||
|
<button
|
||||||
|
id="modalZapBtn"
|
||||||
|
type="button"
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="Send a zap"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/lightning-bolt.svg"
|
||||||
|
alt="Zap"
|
||||||
|
class="icon-image"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
<!-- Share Button (circular) -->
|
<!-- Share Button (circular) -->
|
||||||
<button id="shareBtn" class="icon-button">
|
<button id="shareBtn" class="icon-button">
|
||||||
<img
|
<img
|
||||||
|
|||||||
+3
-3
@@ -46,9 +46,9 @@
|
|||||||
- Add zaps to videos, profiles, and comments.
|
- Add zaps to videos, profiles, and comments.
|
||||||
- Create a VRR (View, Rating, and Retention) Penalty Scoring system. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/07_bitvid_Enhanced_View_Rating_%26_Retention_Penalty_Scoring.md)
|
- Create a VRR (View, Rating, and Retention) Penalty Scoring system. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/07_bitvid_Enhanced_View_Rating_%26_Retention_Penalty_Scoring.md)
|
||||||
- Implement visibility filtering for videos:
|
- Implement visibility filtering for videos:
|
||||||
- Show only videos whose magnet links have at least **one active peer online**.
|
- Run lightweight URL-health probes so cards stay visible when the hosted playback link responds.
|
||||||
- Integrate the filtering mechanism into the video list rendering process.
|
- When a URL is unreachable, fall back to WebTorrent and surface items only if their magnets can stream via peers or web seeds.
|
||||||
- Update the video list dynamically based on real-time peer availability. (this may not be possible)
|
- Align the filtering logic with the player’s URL-first, WebTorrent-fallback behavior during list rendering.
|
||||||
- Dynamic Home Page and Video Tracking Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_%26_Video_Tracking_Specification.md)
|
- Dynamic Home Page and Video Tracking Specification. [Click for more details](https://github.com/PR0M3TH3AN/bitvid/blob/main/content/roadmap/08_bitvid_Enhanced_Dynamic_Home_Page_%26_Video_Tracking_Specification.md)
|
||||||
- Add multi-language support for content and filtration. (v4?)
|
- Add multi-language support for content and filtration. (v4?)
|
||||||
- Create a settings menu for local account preferences, including relay, adult content, theme, and language.
|
- Create a settings menu for local account preferences, including relay, adult content, theme, and language.
|
||||||
|
|||||||
+151
-1
@@ -8,6 +8,7 @@
|
|||||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
--modal-motion-duration: 160ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Core Styles */
|
/* Core Styles */
|
||||||
@@ -73,6 +74,92 @@ header img {
|
|||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-card--enter {
|
||||||
|
opacity: 0;
|
||||||
|
animation: video-card-fade-in 220ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes video-card-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Thumbnails start transparent and fade in the first time they finish loading.
|
||||||
|
`data-thumbnail-loaded` is cleared by js/index.js once the animation completes
|
||||||
|
so re-rendering the same element later does not replay the transition and cause
|
||||||
|
a distracting flash.
|
||||||
|
*/
|
||||||
|
[data-video-thumbnail][data-thumbnail-loaded="true"] {
|
||||||
|
animation: video-thumbnail-fade-in 220ms ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes video-thumbnail-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Generic fade-in utility for chrome elements (sidebar, header controls, etc.).
|
||||||
|
js/index.js listens for the `interface-fade-in` animation finishing and removes
|
||||||
|
the temporary classes so that once an element has gracefully appeared it stays
|
||||||
|
opaque even if the DOM is re-used or re-parented later.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
opacity: 0;
|
||||||
|
animation: interface-fade-in 280ms ease-out forwards;
|
||||||
|
animation-delay: var(--fade-in-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-delay-100 {
|
||||||
|
--fade-in-delay: 100ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-delay-200 {
|
||||||
|
--fade-in-delay: 200ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-delay-300 {
|
||||||
|
--fade-in-delay: 300ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in-delay-400 {
|
||||||
|
--fade-in-delay: 400ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes interface-fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.video-card--enter,
|
||||||
|
.fade-in {
|
||||||
|
opacity: 1;
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-video-thumbnail][data-thumbnail-loaded="true"] {
|
||||||
|
animation: none;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.video-card:hover {
|
.video-card:hover {
|
||||||
transform: translateY(-4px);
|
transform: translateY(-4px);
|
||||||
box-shadow: var(--shadow-lg);
|
box-shadow: var(--shadow-lg);
|
||||||
@@ -137,7 +224,7 @@ header img {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: #0f172a;
|
background-color: #0f172a;
|
||||||
animation: fadeIn 0.3s ease-in-out;
|
animation: fadeIn var(--modal-motion-duration) ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
@@ -151,6 +238,12 @@ header img {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.modal-content {
|
||||||
|
animation-duration: 1ms;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Video Container */
|
/* Video Container */
|
||||||
.video-container {
|
.video-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -541,3 +634,60 @@ footer a:hover {
|
|||||||
#sidebar hr {
|
#sidebar hr {
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sidebar drop-up menu styling */
|
||||||
|
.sidebar-dropup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-panel {
|
||||||
|
background-color: #1f2937; /* gray-800 */
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 18px 35px rgba(15, 23, 42, 0.45);
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(16px);
|
||||||
|
transition: max-height 0.35s ease, opacity 0.25s ease, transform 0.35s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-panel__inner {
|
||||||
|
padding: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-panel--expanded {
|
||||||
|
max-height: 640px;
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-trigger {
|
||||||
|
align-items: center;
|
||||||
|
background: linear-gradient(135deg, rgba(59, 130, 246, 0.25), rgba(14, 116, 144, 0.35));
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 9999px;
|
||||||
|
color: #f8fafc;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-trigger:hover,
|
||||||
|
.sidebar-dropup-trigger:focus {
|
||||||
|
border-color: rgba(148, 163, 184, 0.6);
|
||||||
|
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.25);
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-dropup-trigger__content {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
# Bitvid Manual QA Script
|
||||||
|
|
||||||
|
Run this checklist before publishing releases or merging changes that touch upload or playback flows. Copy/paste the steps into QA tickets when requesting validation.
|
||||||
|
|
||||||
|
1. **Upload Modal Smoke Test**
|
||||||
|
- Open the Upload modal, verify title is required, and ensure either a hosted URL or a magnet (or both) must be supplied.
|
||||||
|
- Submit three variants: URL-only, magnet-only, and both URL + magnet. Confirm success messaging or event publication in each case.
|
||||||
|
2. **URL-First Playback**
|
||||||
|
- For a post with both URL and magnet, play the video and confirm the hosted URL loads first (inspect network requests or logs).
|
||||||
|
- Simulate a dead URL (offline mode or request blocker) and confirm playback falls back to WebTorrent without breaking the UI.
|
||||||
|
3. **Magnet Safety**
|
||||||
|
- Paste an encoded magnet and confirm `safeDecodeMagnet()` outputs the raw string before playback.
|
||||||
|
- Ensure `normalizeAndAugmentMagnet()` adds provided `ws=` / `xs=` hints while keeping the original `xt` hash intact.
|
||||||
|
4. **P2P Hints & Trackers**
|
||||||
|
- Confirm magnets generated through the modal include HTTPS `ws=` seeds and optional HTTPS `xs=` torrent URLs when supplied.
|
||||||
|
- Check that tracker arrays originate from `js/constants.js` and only include WSS endpoints.
|
||||||
|
5. **Cross-Browser Verification**
|
||||||
|
- Spot-check Chromium and Firefox (desktop) for console warnings about CORS, Range requests, or tracker connectivity.
|
||||||
|
- Optionally validate on mobile Safari/Chrome if the change targets mobile UX.
|
||||||
|
|
||||||
|
Document findings (pass/fail notes plus relevant screenshots or logs) so they can be attached to release or PR notes.
|
||||||
+24
-5
@@ -39,6 +39,7 @@
|
|||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
|
<script src="js/tracking.js" defer></script>
|
||||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||||
<link href="css/style.css" rel="stylesheet" />
|
<link href="css/style.css" rel="stylesheet" />
|
||||||
<link href="css/markdown.css" rel="stylesheet" />
|
<link href="css/markdown.css" rel="stylesheet" />
|
||||||
@@ -58,14 +59,25 @@
|
|||||||
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
|
md:ml-64 ensures content is shifted on desktop so the pinned sidebar doesn't overlap.
|
||||||
On mobile, we also toggle .sidebar-open to shift the entire content.
|
On mobile, we also toggle .sidebar-open to shift the entire content.
|
||||||
-->
|
-->
|
||||||
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col">
|
<!--
|
||||||
|
`fade-in` is scrubbed by js/index.js after the first animation so that
|
||||||
|
re-rendering views does not cause the shell to flicker. Only apply it to
|
||||||
|
elements that should animate exactly once on initial paint.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<div id="app" class="md:ml-64 px-4 py-8 min-h-screen flex flex-col fade-in">
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="mb-8 flex items-center w-full">
|
<header class="mb-8 flex items-center w-full">
|
||||||
<!-- Mobile hamburger button (hidden on md+) -->
|
<!-- Mobile hamburger button (hidden on md+) -->
|
||||||
<!-- New button -->
|
<!-- New button -->
|
||||||
|
<!--
|
||||||
|
Mobile menu button: uses `fade-in` to match the rest of the chrome.
|
||||||
|
The JS animation cleanup removes the helper classes once the entrance
|
||||||
|
finishes to prevent flashes on future sidebar toggles.
|
||||||
|
-->
|
||||||
<button
|
<button
|
||||||
id="mobileMenuBtn"
|
id="mobileMenuBtn"
|
||||||
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60]"
|
class="md:hidden ml-2 mr-4 flex items-center justify-center w-10 h-10 rounded-full bg-transparent hover:bg-transparent focus:outline-none focus:ring-2 focus:ring-[#0f172a] z-[60] fade-in"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
||||||
@@ -74,14 +86,18 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
|
<!-- Logo fades in once on initial load. -->
|
||||||
<img
|
<img
|
||||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||||
alt="BitVid Logo"
|
alt="BitVid Logo"
|
||||||
class="h-16"
|
class="h-16 fade-in fade-in-delay-100"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- Buttons on the far right -->
|
<!-- Buttons on the far right -->
|
||||||
<div class="ml-auto flex items-center space-x-4">
|
|
||||||
|
<!-- Header action buttons share the fade-in treatment for consistency. -->
|
||||||
|
|
||||||
|
<div class="ml-auto flex items-center space-x-4 fade-in fade-in-delay-200">
|
||||||
<!-- Login Button -->
|
<!-- Login Button -->
|
||||||
<button
|
<button
|
||||||
id="loginButton"
|
id="loginButton"
|
||||||
@@ -140,7 +156,10 @@
|
|||||||
<div id="modalContainer"></div>
|
<div id="modalContainer"></div>
|
||||||
|
|
||||||
<!-- Tagline / Slogan -->
|
<!-- Tagline / Slogan -->
|
||||||
<div class="text-center mb-8">
|
|
||||||
|
<!-- Tagline fade matches the cards. -->
|
||||||
|
|
||||||
|
<div class="text-center mb-8 fade-in fade-in-delay-300">
|
||||||
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
||||||
seed. zap. subscribe.
|
seed. zap. subscribe.
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
+92
-18
@@ -7,44 +7,118 @@ class AccessControl {
|
|||||||
constructor() {
|
constructor() {
|
||||||
// Debug logging for initialization
|
// Debug logging for initialization
|
||||||
console.log("DEBUG: AccessControl constructor called");
|
console.log("DEBUG: AccessControl constructor called");
|
||||||
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
|
|
||||||
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
|
|
||||||
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
|
|
||||||
|
|
||||||
// Initialize empty sets
|
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
|
||||||
this.whitelist = new Set(initialWhitelist);
|
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
|
||||||
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
|
||||||
|
if (storedWhitelist !== null) {
|
||||||
|
this.whitelist = new Set(storedWhitelist);
|
||||||
|
} else {
|
||||||
|
this.whitelist = new Set(initialWhitelist);
|
||||||
|
this.saveWhitelist();
|
||||||
|
if (whitelistStatus && whitelistStatus !== "missing") {
|
||||||
|
console.warn(
|
||||||
|
`Whitelist storage ${whitelistStatus}. Falling back to initial whitelist.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedBlacklist !== null) {
|
||||||
|
this.blacklist = new Set(storedBlacklist);
|
||||||
|
} else {
|
||||||
|
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
||||||
|
this.saveBlacklist();
|
||||||
|
if (blacklistStatus && blacklistStatus !== "missing") {
|
||||||
|
console.warn(
|
||||||
|
`Blacklist storage ${blacklistStatus}. Falling back to initial blacklist.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Debug the sets
|
// Debug the sets
|
||||||
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
|
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
|
||||||
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
|
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
|
||||||
|
|
||||||
// Save to localStorage
|
|
||||||
this.saveWhitelist();
|
|
||||||
this.saveBlacklist();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rest of the class remains the same...
|
// Rest of the class remains the same...
|
||||||
loadWhitelist() {
|
loadWhitelist() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("bitvid_whitelist");
|
const stored = localStorage.getItem("bitvid_whitelist");
|
||||||
return stored ? JSON.parse(stored) : [];
|
if (!stored) {
|
||||||
|
if (isDevMode) console.log("No stored whitelist found in localStorage.");
|
||||||
|
return { data: null, status: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const sanitized = this.sanitizeList(parsed, "whitelist");
|
||||||
|
return {
|
||||||
|
data: sanitized,
|
||||||
|
status: sanitized === null ? "invalid" : "ok",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading whitelist:", error);
|
console.error("Error loading whitelist, using defaults:", error);
|
||||||
return [];
|
return { data: null, status: "error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadBlacklist() {
|
loadBlacklist() {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem("bitvid_blacklist");
|
const stored = localStorage.getItem("bitvid_blacklist");
|
||||||
return stored ? JSON.parse(stored) : [];
|
if (!stored) {
|
||||||
|
if (isDevMode) console.log("No stored blacklist found in localStorage.");
|
||||||
|
return { data: null, status: "missing" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = JSON.parse(stored);
|
||||||
|
const sanitized = this.sanitizeList(parsed, "blacklist");
|
||||||
|
return {
|
||||||
|
data: sanitized,
|
||||||
|
status: sanitized === null ? "invalid" : "ok",
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error loading blacklist:", error);
|
console.error("Error loading blacklist, using defaults:", error);
|
||||||
return [];
|
return { data: null, status: "error" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sanitizeList(list, listName) {
|
||||||
|
if (!Array.isArray(list)) {
|
||||||
|
console.warn(
|
||||||
|
`Stored ${listName} is not an array. Received:`,
|
||||||
|
list
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitized = [];
|
||||||
|
let invalidEntries = 0;
|
||||||
|
|
||||||
|
list.forEach((value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
invalidEntries += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (trimmed) {
|
||||||
|
sanitized.push(trimmed);
|
||||||
|
} else {
|
||||||
|
invalidEntries += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidEntries > 0) {
|
||||||
|
console.warn(
|
||||||
|
`Stored ${listName} contained ${invalidEntries} invalid entr${
|
||||||
|
invalidEntries === 1 ? "y" : "ies"
|
||||||
|
}. Sanitized list:`,
|
||||||
|
sanitized
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
saveWhitelist() {
|
saveWhitelist() {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
|
|||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
// js/analytics.js
|
||||||
|
import { ANALYTICS_CONFIG } from "./analyticsConfig.js";
|
||||||
|
|
||||||
|
const SCRIPT_ATTR = "data-bitvid-analytics";
|
||||||
|
const SCRIPT_IDENTIFIER = "umami";
|
||||||
|
const pendingCalls = [];
|
||||||
|
let flushTimerId = null;
|
||||||
|
let scriptLoadedOnce = false;
|
||||||
|
|
||||||
|
function isBrowserEnvironment() {
|
||||||
|
return typeof window !== "undefined" && typeof document !== "undefined";
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingCalls() {
|
||||||
|
if (!isBrowserEnvironment()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const umami = window.umami;
|
||||||
|
if (!umami) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (pendingCalls.length > 0) {
|
||||||
|
const { method, args } = pendingCalls.shift();
|
||||||
|
const fn = typeof umami[method] === "function" ? umami[method] : null;
|
||||||
|
if (!fn) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fn.apply(umami, args);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[analytics] Failed to call", method, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFlush() {
|
||||||
|
if (!isBrowserEnvironment()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flushTimerId !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||||
|
flushTimerId = window.setInterval(() => {
|
||||||
|
if (window.umami && typeof window.umami.track === "function") {
|
||||||
|
window.clearInterval(flushTimerId);
|
||||||
|
flushTimerId = null;
|
||||||
|
flushPendingCalls();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ensureAnalyticsLoaded(doc = typeof document !== "undefined" ? document : null) {
|
||||||
|
if (!doc) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let script = doc.querySelector(`script[${SCRIPT_ATTR}]`);
|
||||||
|
if (script) {
|
||||||
|
if (!scriptLoadedOnce) {
|
||||||
|
// If a server rendered script already exists, make sure we flush asap.
|
||||||
|
scriptLoadedOnce = true;
|
||||||
|
flushPendingCalls();
|
||||||
|
}
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
script = doc.createElement("script");
|
||||||
|
script.defer = true;
|
||||||
|
script.src = ANALYTICS_CONFIG.scriptSrc;
|
||||||
|
script.setAttribute(SCRIPT_ATTR, SCRIPT_IDENTIFIER);
|
||||||
|
script.dataset.websiteId = ANALYTICS_CONFIG.websiteId;
|
||||||
|
// Allow Umami to handle automatic pageview tracking by default.
|
||||||
|
script.addEventListener("load", () => {
|
||||||
|
scriptLoadedOnce = true;
|
||||||
|
flushPendingCalls();
|
||||||
|
});
|
||||||
|
doc.head.appendChild(script);
|
||||||
|
|
||||||
|
scheduleFlush();
|
||||||
|
|
||||||
|
return script;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueCall(method, args) {
|
||||||
|
pendingCalls.push({ method, args });
|
||||||
|
scheduleFlush();
|
||||||
|
}
|
||||||
|
|
||||||
|
function invokeUmami(method, args) {
|
||||||
|
if (!isBrowserEnvironment()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAnalyticsLoaded();
|
||||||
|
|
||||||
|
const umami = window.umami;
|
||||||
|
if (umami && typeof umami[method] === "function") {
|
||||||
|
try {
|
||||||
|
umami[method].apply(umami, args);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[analytics] Failed to call", method, err);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
queueCall(method, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackPageView(path, referrer) {
|
||||||
|
if (!isBrowserEnvironment()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPath =
|
||||||
|
typeof path === "string" && path.length > 0
|
||||||
|
? path
|
||||||
|
: `${window.location.pathname}${window.location.hash || ""}`;
|
||||||
|
|
||||||
|
const resolvedReferrer =
|
||||||
|
typeof referrer === "string" ? referrer : document.referrer || "";
|
||||||
|
|
||||||
|
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||||
|
let absoluteUrl = resolvedPath;
|
||||||
|
try {
|
||||||
|
absoluteUrl = new URL(resolvedPath, window.location.origin).toString();
|
||||||
|
} catch (err) {
|
||||||
|
// Fall back to the provided path if it cannot be resolved.
|
||||||
|
}
|
||||||
|
|
||||||
|
let absoluteReferrer = resolvedReferrer;
|
||||||
|
if (resolvedReferrer) {
|
||||||
|
try {
|
||||||
|
absoluteReferrer = new URL(resolvedReferrer, window.location.origin).toString();
|
||||||
|
} catch (err) {
|
||||||
|
// Keep original referrer if it cannot be normalized.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeUmami("track", [
|
||||||
|
(basePayload = {}) => ({
|
||||||
|
...basePayload,
|
||||||
|
url: absoluteUrl || basePayload.url,
|
||||||
|
referrer: absoluteReferrer || basePayload.referrer,
|
||||||
|
}),
|
||||||
|
"pageview",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackVideoView({
|
||||||
|
videoId,
|
||||||
|
title,
|
||||||
|
source,
|
||||||
|
hasMagnet,
|
||||||
|
hasUrl,
|
||||||
|
} = {}) {
|
||||||
|
if (!isBrowserEnvironment()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {};
|
||||||
|
|
||||||
|
if (videoId) {
|
||||||
|
payload.videoId = String(videoId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
payload.title = String(title);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source) {
|
||||||
|
payload.source = String(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof hasMagnet === "boolean") {
|
||||||
|
payload.hasMagnet = hasMagnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof hasUrl === "boolean") {
|
||||||
|
payload.hasUrl = hasUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔧 merged conflicting changes from codex/add-tracking-script-to-all-pages vs unstable
|
||||||
|
invokeUmami("track", [ANALYTICS_CONFIG.videoViewEventName, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Immediately queue the analytics script so page views are captured early.
|
||||||
|
if (isBrowserEnvironment()) {
|
||||||
|
ensureAnalyticsLoaded();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// js/analyticsConfig.js
|
||||||
|
// Central configuration for site-wide analytics tracking.
|
||||||
|
|
||||||
|
export const ANALYTICS_CONFIG = Object.freeze({
|
||||||
|
/**
|
||||||
|
* Hosted Umami script that powers analytics.
|
||||||
|
* Update this value if the tracker is relocated.
|
||||||
|
*/
|
||||||
|
scriptSrc: "https://umami.malin.onl/script.js",
|
||||||
|
/**
|
||||||
|
* The Umami website identifier for this deployment.
|
||||||
|
*/
|
||||||
|
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
|
||||||
|
/**
|
||||||
|
* Event name used when recording individual video views.
|
||||||
|
*/
|
||||||
|
videoViewEventName: "video_view",
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { Buffer } from "https://esm.sh/[email protected]?bundle";
|
||||||
|
|
||||||
|
const globalScope = typeof globalThis !== "undefined" ? globalThis : window;
|
||||||
|
|
||||||
|
if (globalScope && !globalScope.Buffer) {
|
||||||
|
globalScope.Buffer = Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalScope && !globalScope.global) {
|
||||||
|
globalScope.global = globalScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalScope && !globalScope.process) {
|
||||||
|
globalScope.process = { env: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Buffer };
|
||||||
+161
-65
@@ -1,8 +1,12 @@
|
|||||||
// js/channelProfile.js
|
// js/channelProfile.js
|
||||||
|
|
||||||
import { nostrClient } from "./nostr.js";
|
import {
|
||||||
|
nostrClient,
|
||||||
|
convertEventToVideo as sharedConvertEventToVideo,
|
||||||
|
} from "./nostr.js";
|
||||||
import { app } from "./app.js";
|
import { app } from "./app.js";
|
||||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||||
|
import { attachHealthBadges } from "./gridHealth.js";
|
||||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||||
import { isWhitelistEnabled } from "./config.js";
|
import { isWhitelistEnabled } from "./config.js";
|
||||||
|
|
||||||
@@ -44,6 +48,8 @@ export async function initChannelProfileView() {
|
|||||||
if (btn) btn.classList.add("hidden");
|
if (btn) btn.classList.add("hidden");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupZapButton();
|
||||||
|
|
||||||
// 4) Load user’s profile (banner, avatar, etc.)
|
// 4) Load user’s profile (banner, avatar, etc.)
|
||||||
await loadUserProfile(hexPub);
|
await loadUserProfile(hexPub);
|
||||||
|
|
||||||
@@ -51,6 +57,22 @@ export async function initChannelProfileView() {
|
|||||||
await loadUserVideos(hexPub);
|
await loadUserVideos(hexPub);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setupZapButton() {
|
||||||
|
const zapButton = document.getElementById("zapButton");
|
||||||
|
if (!zapButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zapButton.dataset.initialized === "true") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zapButton.addEventListener("click", () => {
|
||||||
|
window.alert("Zaps coming soon.");
|
||||||
|
});
|
||||||
|
zapButton.dataset.initialized = "true";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders a Subscribe / Unsubscribe button with an icon,
|
* Renders a Subscribe / Unsubscribe button with an icon,
|
||||||
* using color #fe0032 and the subscribe-button-icon.svg on the left.
|
* using color #fe0032 and the subscribe-button-icon.svg on the left.
|
||||||
@@ -199,14 +221,14 @@ async function loadUserVideos(pubkey) {
|
|||||||
// 3) Convert to "video" objects
|
// 3) Convert to "video" objects
|
||||||
let videos = [];
|
let videos = [];
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
const vid = localConvertEventToVideo(evt);
|
const vid = sharedConvertEventToVideo(evt);
|
||||||
if (!vid.invalid && !vid.deleted) {
|
if (!vid.invalid && !vid.deleted) {
|
||||||
videos.push(vid);
|
videos.push(vid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Deduplicate older overshadowed versions => newest only
|
// 4) Deduplicate older overshadowed versions => newest only
|
||||||
videos = dedupeToNewestByRoot(videos);
|
videos = app?.dedupeVideosByRoot?.(videos) ?? dedupeToNewestByRoot(videos);
|
||||||
|
|
||||||
// 5) Filter out blacklisted IDs / authors
|
// 5) Filter out blacklisted IDs / authors
|
||||||
videos = videos.filter((video) => {
|
videos = videos.filter((video) => {
|
||||||
@@ -251,6 +273,10 @@ async function loadUserVideos(pubkey) {
|
|||||||
video.alreadyDecrypted = true;
|
video.alreadyDecrypted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure the global videos map is kept up to date so delegated handlers
|
||||||
|
// have the freshest metadata for this event.
|
||||||
|
window.app?.videosMap?.set(video.id, video);
|
||||||
|
|
||||||
// Check if user can edit
|
// Check if user can edit
|
||||||
const canEdit = video.pubkey === app.pubkey;
|
const canEdit = video.pubkey === app.pubkey;
|
||||||
let hasOlder = false;
|
let hasOlder = false;
|
||||||
@@ -264,6 +290,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||||
hover:bg-red-700 hover:text-white"
|
hover:bg-red-700 hover:text-white"
|
||||||
data-revert-index="${index}"
|
data-revert-index="${index}"
|
||||||
|
data-revert-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Revert
|
Revert
|
||||||
</button>
|
</button>
|
||||||
@@ -298,6 +325,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100
|
class="block w-full text-left px-4 py-2 text-sm text-gray-100
|
||||||
hover:bg-gray-700"
|
hover:bg-gray-700"
|
||||||
data-edit-index="${index}"
|
data-edit-index="${index}"
|
||||||
|
data-edit-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -306,6 +334,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
class="block w-full text-left px-4 py-2 text-sm text-red-400
|
||||||
hover:bg-red-700 hover:text-white"
|
hover:bg-red-700 hover:text-white"
|
||||||
data-delete-all-index="${index}"
|
data-delete-all-index="${index}"
|
||||||
|
data-delete-all-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Delete All
|
Delete All
|
||||||
</button>
|
</button>
|
||||||
@@ -322,6 +351,7 @@ async function loadUserVideos(pubkey) {
|
|||||||
|
|
||||||
const cardEl = document.createElement("div");
|
const cardEl = document.createElement("div");
|
||||||
cardEl.classList.add(
|
cardEl.classList.add(
|
||||||
|
"video-card",
|
||||||
"bg-gray-900",
|
"bg-gray-900",
|
||||||
"rounded-lg",
|
"rounded-lg",
|
||||||
"overflow-hidden",
|
"overflow-hidden",
|
||||||
@@ -331,8 +361,42 @@ async function loadUserVideos(pubkey) {
|
|||||||
"duration-300"
|
"duration-300"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const rawMagnet =
|
||||||
|
typeof video.magnet === "string" ? video.magnet : "";
|
||||||
|
const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
|
||||||
|
const legacyInfoHash =
|
||||||
|
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
|
||||||
|
const playbackUrl =
|
||||||
|
typeof video.url === "string" ? video.url : "";
|
||||||
|
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
|
||||||
|
const playbackMagnet = trimmedMagnet || legacyInfoHash || "";
|
||||||
|
const magnetProvided = playbackMagnet.length > 0;
|
||||||
|
const magnetSupported = app.isMagnetUriSupported(playbackMagnet);
|
||||||
|
const showUnsupportedTorrentBadge =
|
||||||
|
!trimmedUrl && magnetProvided && !magnetSupported;
|
||||||
|
const urlBadgeHtml = trimmedUrl
|
||||||
|
? app.getUrlHealthPlaceholderMarkup({ includeMargin: false })
|
||||||
|
: "";
|
||||||
|
const torrentHealthBadgeHtml =
|
||||||
|
magnetSupported && magnetProvided
|
||||||
|
? app.getTorrentHealthBadgeMarkup({ includeMargin: false })
|
||||||
|
: "";
|
||||||
|
const connectionBadgesHtml =
|
||||||
|
urlBadgeHtml || torrentHealthBadgeHtml
|
||||||
|
? `
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
${urlBadgeHtml}${torrentHealthBadgeHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
cardEl.innerHTML = `
|
cardEl.innerHTML = `
|
||||||
<div class="cursor-pointer relative group">
|
<div
|
||||||
|
class="cursor-pointer relative group"
|
||||||
|
data-video-id="${video.id}"
|
||||||
|
data-play-url=""
|
||||||
|
data-play-magnet=""
|
||||||
|
>
|
||||||
<div class="ratio-16-9">
|
<div class="ratio-16-9">
|
||||||
<img
|
<img
|
||||||
src="${fallbackThumb}"
|
src="${fallbackThumb}"
|
||||||
@@ -341,32 +405,86 @@ async function loadUserVideos(pubkey) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-4 flex items-center justify-between">
|
<div class="p-4">
|
||||||
<div>
|
<div class="flex items-center justify-between">
|
||||||
<h3
|
<div>
|
||||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
<h3
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||||
>
|
data-video-id="${video.id}"
|
||||||
${safeTitle}
|
data-play-url=""
|
||||||
</h3>
|
data-play-magnet=""
|
||||||
<p class="text-sm text-gray-500">
|
>
|
||||||
${new Date(video.created_at * 1000).toLocaleString()}
|
${safeTitle}
|
||||||
</p>
|
</h3>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
${new Date(video.created_at * 1000).toLocaleString()}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
${gearMenu}
|
||||||
</div>
|
</div>
|
||||||
${gearMenu}
|
${connectionBadgesHtml}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Clicking the card => open the video modal
|
if (showUnsupportedTorrentBadge) {
|
||||||
cardEl.addEventListener("click", () => {
|
cardEl.dataset.torrentSupported = "false";
|
||||||
app.playVideoByEventId(video.id);
|
} else if (magnetProvided && magnetSupported) {
|
||||||
|
cardEl.dataset.torrentSupported = "true";
|
||||||
|
} else if (cardEl.dataset.torrentSupported) {
|
||||||
|
delete cardEl.dataset.torrentSupported;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (magnetProvided) {
|
||||||
|
cardEl.dataset.magnet = playbackMagnet;
|
||||||
|
} else if (cardEl.dataset.magnet) {
|
||||||
|
delete cardEl.dataset.magnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leave the data-play-* attributes empty in the template markup so the raw
|
||||||
|
// URL/magnet strings can be assigned after parsing without HTML entity
|
||||||
|
// escaping, keeping this renderer consistent with app.js. The stored URL is
|
||||||
|
// encoded so it stays intact within data-* attributes, and the click
|
||||||
|
// handler decodes it while leaving magnets untouched until
|
||||||
|
// safeDecodeMagnet() runs.
|
||||||
|
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
|
||||||
|
interactiveEls.forEach((el) => {
|
||||||
|
if (!el.dataset) return;
|
||||||
|
|
||||||
|
if (trimmedUrl) {
|
||||||
|
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
|
||||||
|
} else {
|
||||||
|
delete el.dataset.playUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.dataset.playMagnet = playbackMagnet || "";
|
||||||
|
if (magnetProvided) {
|
||||||
|
el.dataset.torrentSupported = magnetSupported ? "true" : "false";
|
||||||
|
} else if (el.dataset.torrentSupported) {
|
||||||
|
delete el.dataset.torrentSupported;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (trimmedUrl) {
|
||||||
|
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||||
|
if (badgeEl) {
|
||||||
|
app.handleUrlHealthBadge({
|
||||||
|
video,
|
||||||
|
url: trimmedUrl,
|
||||||
|
badgeEl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fragment.appendChild(cardEl);
|
fragment.appendChild(cardEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(fragment);
|
container.appendChild(fragment);
|
||||||
|
|
||||||
|
attachHealthBadges(container);
|
||||||
|
|
||||||
|
window.app.videoList = container;
|
||||||
|
window.app.attachVideoListHandler();
|
||||||
|
|
||||||
// Lazy-load images
|
// Lazy-load images
|
||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||||
@@ -389,10 +507,15 @@ async function loadUserVideos(pubkey) {
|
|||||||
editBtns.forEach((btn) => {
|
editBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
const idxAttr = btn.getAttribute("data-edit-index");
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleEditVideo(idx);
|
const eventId = btn.getAttribute("data-edit-event-id") || "";
|
||||||
|
app.handleEditVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -401,10 +524,15 @@ async function loadUserVideos(pubkey) {
|
|||||||
revertBtns.forEach((btn) => {
|
revertBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
const idxAttr = btn.getAttribute("data-revert-index");
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleRevertVideo(idx);
|
const eventId = btn.getAttribute("data-revert-event-id") || "";
|
||||||
|
app.handleRevertVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -413,10 +541,15 @@ async function loadUserVideos(pubkey) {
|
|||||||
deleteAllBtns.forEach((btn) => {
|
deleteAllBtns.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
app.handleFullDeleteVideo(idx);
|
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
|
||||||
|
app.handleFullDeleteVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -446,43 +579,6 @@ function dedupeToNewestByRoot(videos) {
|
|||||||
return Array.from(map.values());
|
return Array.from(map.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert raw event => "video" object.
|
|
||||||
*/
|
|
||||||
function localConvertEventToVideo(event) {
|
|
||||||
try {
|
|
||||||
const content = JSON.parse(event.content || "{}");
|
|
||||||
const isSupportedVersion = content.version >= 2;
|
|
||||||
const hasRequiredFields = !!(content.title && content.magnet);
|
|
||||||
|
|
||||||
if (!isSupportedVersion) {
|
|
||||||
return { id: event.id, invalid: true, reason: "version <2" };
|
|
||||||
}
|
|
||||||
if (!hasRequiredFields) {
|
|
||||||
return { id: event.id, invalid: true, reason: "missing title/magnet" };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: event.id,
|
|
||||||
videoRootId: content.videoRootId || event.id,
|
|
||||||
version: content.version,
|
|
||||||
isPrivate: content.isPrivate ?? false,
|
|
||||||
title: content.title ?? "",
|
|
||||||
magnet: content.magnet ?? "",
|
|
||||||
thumbnail: content.thumbnail ?? "",
|
|
||||||
description: content.description ?? "",
|
|
||||||
mode: content.mode ?? "live",
|
|
||||||
deleted: content.deleted === true,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
tags: event.tags,
|
|
||||||
invalid: false,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return { id: event.id, invalid: true, reason: "json parse error" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Basic escaping to avoid XSS.
|
* Basic escaping to avoid XSS.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+159
@@ -0,0 +1,159 @@
|
|||||||
|
const DEFAULT_FLAGS = Object.freeze({
|
||||||
|
URL_FIRST_ENABLED: true, // try URL before magnet in the player
|
||||||
|
ACCEPT_LEGACY_V1: true, // accept v1 magnet-only notes
|
||||||
|
WSS_TRACKERS: Object.freeze([
|
||||||
|
"wss://tracker.openwebtorrent.com",
|
||||||
|
"wss://tracker.btorrent.xyz",
|
||||||
|
"wss://tracker.webtorrent.dev",
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalScope = typeof globalThis === "object" && globalThis ? globalThis : undefined;
|
||||||
|
|
||||||
|
const runtimeFlags = (() => {
|
||||||
|
if (globalScope && typeof globalScope.__BITVID_RUNTIME_FLAGS__ === "object") {
|
||||||
|
return globalScope.__BITVID_RUNTIME_FLAGS__;
|
||||||
|
}
|
||||||
|
const initial = {
|
||||||
|
URL_FIRST_ENABLED: DEFAULT_FLAGS.URL_FIRST_ENABLED,
|
||||||
|
ACCEPT_LEGACY_V1: DEFAULT_FLAGS.ACCEPT_LEGACY_V1,
|
||||||
|
WSS_TRACKERS: [...DEFAULT_FLAGS.WSS_TRACKERS],
|
||||||
|
};
|
||||||
|
if (globalScope) {
|
||||||
|
globalScope.__BITVID_RUNTIME_FLAGS__ = initial;
|
||||||
|
}
|
||||||
|
return initial;
|
||||||
|
})();
|
||||||
|
|
||||||
|
function coerceBoolean(value, fallback) {
|
||||||
|
if (typeof value === "boolean") {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const normalized = value.trim().toLowerCase();
|
||||||
|
if (!normalized) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
if (["false", "0", "off", "no"].includes(normalized)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (["true", "1", "on", "yes"].includes(normalized)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (value == null) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return Boolean(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeTrackerList(candidate) {
|
||||||
|
const input = Array.isArray(candidate) ? candidate : DEFAULT_FLAGS.WSS_TRACKERS;
|
||||||
|
const seen = new Set();
|
||||||
|
const sanitized = [];
|
||||||
|
|
||||||
|
for (const tracker of input) {
|
||||||
|
if (typeof tracker !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmed = tracker.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/^wss:\/\//i.test(trimmed)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
if (seen.has(normalized)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
sanitized.push(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sanitized.length) {
|
||||||
|
return [...DEFAULT_FLAGS.WSS_TRACKERS];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function freezeTrackers(list) {
|
||||||
|
return Object.freeze([...list]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export let URL_FIRST_ENABLED = coerceBoolean(
|
||||||
|
runtimeFlags.URL_FIRST_ENABLED,
|
||||||
|
DEFAULT_FLAGS.URL_FIRST_ENABLED
|
||||||
|
);
|
||||||
|
|
||||||
|
export let ACCEPT_LEGACY_V1 = coerceBoolean(
|
||||||
|
runtimeFlags.ACCEPT_LEGACY_V1,
|
||||||
|
DEFAULT_FLAGS.ACCEPT_LEGACY_V1
|
||||||
|
);
|
||||||
|
|
||||||
|
export let WSS_TRACKERS = freezeTrackers(
|
||||||
|
sanitizeTrackerList(runtimeFlags.WSS_TRACKERS)
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.defineProperty(runtimeFlags, "URL_FIRST_ENABLED", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get() {
|
||||||
|
return URL_FIRST_ENABLED;
|
||||||
|
},
|
||||||
|
set(next) {
|
||||||
|
URL_FIRST_ENABLED = coerceBoolean(next, DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(runtimeFlags, "ACCEPT_LEGACY_V1", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get() {
|
||||||
|
return ACCEPT_LEGACY_V1;
|
||||||
|
},
|
||||||
|
set(next) {
|
||||||
|
ACCEPT_LEGACY_V1 = coerceBoolean(next, DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.defineProperty(runtimeFlags, "WSS_TRACKERS", {
|
||||||
|
configurable: true,
|
||||||
|
enumerable: true,
|
||||||
|
get() {
|
||||||
|
return [...WSS_TRACKERS];
|
||||||
|
},
|
||||||
|
set(next) {
|
||||||
|
WSS_TRACKERS = freezeTrackers(sanitizeTrackerList(next));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure the runtime object reflects the sanitized defaults immediately.
|
||||||
|
runtimeFlags.URL_FIRST_ENABLED = URL_FIRST_ENABLED;
|
||||||
|
runtimeFlags.ACCEPT_LEGACY_V1 = ACCEPT_LEGACY_V1;
|
||||||
|
runtimeFlags.WSS_TRACKERS = WSS_TRACKERS;
|
||||||
|
|
||||||
|
export function setUrlFirstEnabled(next) {
|
||||||
|
runtimeFlags.URL_FIRST_ENABLED = next;
|
||||||
|
return URL_FIRST_ENABLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAcceptLegacyV1(next) {
|
||||||
|
runtimeFlags.ACCEPT_LEGACY_V1 = next;
|
||||||
|
return ACCEPT_LEGACY_V1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWssTrackers(next) {
|
||||||
|
runtimeFlags.WSS_TRACKERS = next;
|
||||||
|
return WSS_TRACKERS;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetRuntimeFlags() {
|
||||||
|
setUrlFirstEnabled(DEFAULT_FLAGS.URL_FIRST_ENABLED);
|
||||||
|
setAcceptLegacyV1(DEFAULT_FLAGS.ACCEPT_LEGACY_V1);
|
||||||
|
setWssTrackers(DEFAULT_FLAGS.WSS_TRACKERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RUNTIME_FLAGS = runtimeFlags;
|
||||||
|
|
||||||
+18
-6
@@ -2,17 +2,29 @@
|
|||||||
|
|
||||||
class DisclaimerModal {
|
class DisclaimerModal {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Initialize elements when the disclaimer HTML is in the DOM.
|
this.modal = null;
|
||||||
this.init();
|
this.acceptButton = null;
|
||||||
|
this.acceptHandler = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.modal = document.getElementById("disclaimerModal");
|
this.modal = document.getElementById("disclaimerModal");
|
||||||
this.acceptButton = document.getElementById("acceptDisclaimer");
|
const nextAcceptButton = document.getElementById("acceptDisclaimer");
|
||||||
if (this.acceptButton) {
|
|
||||||
this.acceptButton.addEventListener("click", () => {
|
if (!this.acceptHandler) {
|
||||||
|
this.acceptHandler = () => {
|
||||||
this.hide();
|
this.hide();
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.acceptButton && this.acceptHandler) {
|
||||||
|
this.acceptButton.removeEventListener("click", this.acceptHandler);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.acceptButton = nextAcceptButton || null;
|
||||||
|
|
||||||
|
if (this.acceptButton && this.acceptHandler) {
|
||||||
|
this.acceptButton.addEventListener("click", this.acceptHandler);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { infoHashFromMagnet } from "./magnets.js";
|
||||||
|
import { TorrentClient, torrentClient } from "./webtorrent.js";
|
||||||
|
|
||||||
|
const containerState = new WeakMap();
|
||||||
|
const ROOT_MARGIN = "200px 0px";
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCacheEntry(key) {
|
||||||
|
if (!key) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = probeCache.get(key);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (now() - entry.ts > PROBE_CACHE_TTL_MS) {
|
||||||
|
probeCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCacheEntry(key, value) {
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probeCache.set(key, { ts: now(), value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(ts) {
|
||||||
|
if (!Number.isFinite(ts)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour12: false });
|
||||||
|
} catch (err) {
|
||||||
|
return new Date(ts).toLocaleTimeString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTooltip({ peers = 0, checkedAt, reason } = {}) {
|
||||||
|
const parts = [];
|
||||||
|
if (Number.isFinite(peers)) {
|
||||||
|
parts.push(`Peers: ${Math.max(0, peers)}`);
|
||||||
|
}
|
||||||
|
if (Number.isFinite(checkedAt)) {
|
||||||
|
const formatted = formatTime(checkedAt);
|
||||||
|
if (formatted) {
|
||||||
|
parts.push(`Checked ${formatted}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof reason === "string" && reason && reason !== "peer") {
|
||||||
|
let normalizedReason;
|
||||||
|
if (reason === "timeout") {
|
||||||
|
normalizedReason = "Timed out";
|
||||||
|
} else if (reason === "no-trackers") {
|
||||||
|
normalizedReason = "No WSS trackers";
|
||||||
|
} else if (reason === "invalid") {
|
||||||
|
normalizedReason = "Invalid magnet";
|
||||||
|
} else {
|
||||||
|
normalizedReason = reason.charAt(0).toUpperCase() + reason.slice(1);
|
||||||
|
}
|
||||||
|
parts.push(normalizedReason);
|
||||||
|
}
|
||||||
|
if (!parts.length) {
|
||||||
|
return "WebTorrent status unknown";
|
||||||
|
}
|
||||||
|
return `WebTorrent • ${parts.join(" • ")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeResult(result) {
|
||||||
|
const fallback = {
|
||||||
|
healthy: false,
|
||||||
|
peers: 0,
|
||||||
|
reason: "error",
|
||||||
|
appendedTrackers: false,
|
||||||
|
hasProbeTrackers: false,
|
||||||
|
usedTrackers: Array.isArray(TorrentClient.PROBE_TRACKERS)
|
||||||
|
? [...TorrentClient.PROBE_TRACKERS]
|
||||||
|
: [],
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
if (!result || typeof result !== "object") {
|
||||||
|
return { ...fallback, checkedAt: now() };
|
||||||
|
}
|
||||||
|
const peers = Number.isFinite(result.peers)
|
||||||
|
? Math.max(0, Number(result.peers))
|
||||||
|
: 0;
|
||||||
|
const healthy = Boolean(result.healthy) && peers > 0;
|
||||||
|
const reason = typeof result.reason === "string" ? result.reason : "error";
|
||||||
|
return {
|
||||||
|
healthy,
|
||||||
|
peers: healthy ? Math.max(1, peers) : peers,
|
||||||
|
reason,
|
||||||
|
appendedTrackers: Boolean(result.appendedTrackers),
|
||||||
|
hasProbeTrackers:
|
||||||
|
typeof result.hasProbeTrackers === "boolean"
|
||||||
|
? result.hasProbeTrackers
|
||||||
|
: healthy || Boolean(result.appendedTrackers),
|
||||||
|
usedTrackers: Array.isArray(result.usedTrackers)
|
||||||
|
? result.usedTrackers.slice()
|
||||||
|
: fallback.usedTrackers,
|
||||||
|
durationMs: Number.isFinite(result.durationMs)
|
||||||
|
? Math.max(0, Number(result.durationMs))
|
||||||
|
: 0,
|
||||||
|
checkedAt: now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueProbe(magnet, cacheKey) {
|
||||||
|
if (!magnet) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = getCacheEntry(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return Promise.resolve(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeInflight.has(cacheKey)) {
|
||||||
|
return probeInflight.get(cacheKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
const job = probeQueue
|
||||||
|
.run(() =>
|
||||||
|
torrentClient
|
||||||
|
.probePeers(magnet, {
|
||||||
|
timeoutMs: PROBE_TIMEOUT_MS,
|
||||||
|
maxWebConns: 2,
|
||||||
|
polls: PROBE_POLL_COUNT,
|
||||||
|
})
|
||||||
|
.catch((err) => ({
|
||||||
|
healthy: false,
|
||||||
|
peers: 0,
|
||||||
|
reason: "error",
|
||||||
|
error: err,
|
||||||
|
appendedTrackers: false,
|
||||||
|
hasProbeTrackers: false,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.then((result) => {
|
||||||
|
const normalized = normalizeResult(result);
|
||||||
|
if (cacheKey) {
|
||||||
|
setCacheEntry(cacheKey, normalized);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cacheKey) {
|
||||||
|
probeInflight.delete(cacheKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cacheKey) {
|
||||||
|
probeInflight.set(cacheKey, job);
|
||||||
|
}
|
||||||
|
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROBE_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||||
|
const PROBE_TIMEOUT_MS = 8000;
|
||||||
|
const PROBE_CONCURRENCY = 3;
|
||||||
|
const PROBE_POLL_COUNT = 3;
|
||||||
|
|
||||||
|
class ProbeQueue {
|
||||||
|
constructor(max = 2) {
|
||||||
|
this.max = Math.max(1, Number(max) || 1);
|
||||||
|
this.running = 0;
|
||||||
|
this.queue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
run(task) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.queue.push({ task, resolve, reject });
|
||||||
|
this.drain();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
drain() {
|
||||||
|
if (this.running >= this.max) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const job = this.queue.shift();
|
||||||
|
if (!job) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.running += 1;
|
||||||
|
let finished = false;
|
||||||
|
const finalize = () => {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
this.running -= 1;
|
||||||
|
this.drain();
|
||||||
|
};
|
||||||
|
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = job.task();
|
||||||
|
} catch (err) {
|
||||||
|
job.reject(err);
|
||||||
|
finalize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.resolve(result)
|
||||||
|
.then((value) => {
|
||||||
|
job.resolve(value);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
job.reject(err);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
finalize();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeQueue = new ProbeQueue(PROBE_CONCURRENCY);
|
||||||
|
const probeCache = new Map();
|
||||||
|
const probeInflight = new Map();
|
||||||
|
|
||||||
|
function ensureState(container) {
|
||||||
|
let state = containerState.get(container);
|
||||||
|
if (state) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingByCard = new WeakMap();
|
||||||
|
const observedCards = new WeakSet();
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const card = entry.target;
|
||||||
|
if (!(card instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!entry.isIntersecting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleCardVisible({ card, pendingByCard });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ root: null, rootMargin: ROOT_MARGIN, threshold: 0.01 }
|
||||||
|
);
|
||||||
|
|
||||||
|
state = { observer, pendingByCard, observedCards };
|
||||||
|
containerState.set(container, state);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBadge(card, state, details) {
|
||||||
|
const badge = card.querySelector(".torrent-health-badge");
|
||||||
|
if (!badge) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const hadMargin = badge.classList.contains("mt-3");
|
||||||
|
|
||||||
|
const baseClasses = [
|
||||||
|
"torrent-health-badge",
|
||||||
|
"text-xs",
|
||||||
|
"font-semibold",
|
||||||
|
"px-2",
|
||||||
|
"py-1",
|
||||||
|
"rounded",
|
||||||
|
"inline-flex",
|
||||||
|
"items-center",
|
||||||
|
"gap-1",
|
||||||
|
"transition-colors",
|
||||||
|
"duration-200",
|
||||||
|
];
|
||||||
|
if (hadMargin) {
|
||||||
|
baseClasses.unshift("mt-3");
|
||||||
|
}
|
||||||
|
badge.className = baseClasses.join(" ");
|
||||||
|
|
||||||
|
const map = {
|
||||||
|
healthy: {
|
||||||
|
icon: "🟢",
|
||||||
|
aria: "WebTorrent peers available",
|
||||||
|
classes: ["bg-green-900", "text-green-200"],
|
||||||
|
role: "status",
|
||||||
|
},
|
||||||
|
unhealthy: {
|
||||||
|
icon: "🔴",
|
||||||
|
aria: "WebTorrent peers unavailable",
|
||||||
|
classes: ["bg-red-900", "text-red-200"],
|
||||||
|
role: "alert",
|
||||||
|
},
|
||||||
|
checking: {
|
||||||
|
icon: "⏳",
|
||||||
|
aria: "Checking WebTorrent peers",
|
||||||
|
classes: ["bg-gray-800", "text-gray-300"],
|
||||||
|
role: "status",
|
||||||
|
},
|
||||||
|
unknown: {
|
||||||
|
icon: "⚪",
|
||||||
|
aria: "WebTorrent status unknown",
|
||||||
|
classes: ["bg-gray-800", "text-gray-300"],
|
||||||
|
role: "status",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const entry = map[state] || map.unknown;
|
||||||
|
entry.classes.forEach((cls) => badge.classList.add(cls));
|
||||||
|
|
||||||
|
const peers =
|
||||||
|
details && Number.isFinite(details.peers)
|
||||||
|
? Math.max(0, Number(details.peers))
|
||||||
|
: 0;
|
||||||
|
const peersText = state === "healthy" && peers > 0 ? ` (${peers})` : "";
|
||||||
|
|
||||||
|
const iconPrefix = entry.icon ? `${entry.icon} ` : "";
|
||||||
|
badge.textContent = `${iconPrefix}WebTorrent${peersText}`;
|
||||||
|
const tooltip =
|
||||||
|
state === "checking" || state === "unknown"
|
||||||
|
? entry.aria
|
||||||
|
: buildTooltip({
|
||||||
|
peers,
|
||||||
|
checkedAt: details?.checkedAt,
|
||||||
|
reason: details?.reason,
|
||||||
|
});
|
||||||
|
badge.setAttribute("aria-label", tooltip);
|
||||||
|
badge.setAttribute("title", tooltip);
|
||||||
|
badge.setAttribute("aria-live", entry.role === "alert" ? "assertive" : "polite");
|
||||||
|
badge.setAttribute("role", entry.role);
|
||||||
|
badge.dataset.streamHealthState = state;
|
||||||
|
if (Number.isFinite(peers)) {
|
||||||
|
badge.dataset.streamHealthPeers = String(peers);
|
||||||
|
} else if (badge.dataset.streamHealthPeers) {
|
||||||
|
delete badge.dataset.streamHealthPeers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardVisible({ card, pendingByCard }) {
|
||||||
|
if (!(card instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const magnet = card.dataset.magnet || "";
|
||||||
|
if (!magnet) {
|
||||||
|
setBadge(card, "unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const infoHash = infoHashFromMagnet(magnet);
|
||||||
|
if (!infoHash) {
|
||||||
|
setBadge(card, "unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingByCard.has(card)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = getCacheEntry(infoHash);
|
||||||
|
if (cached) {
|
||||||
|
const cachedState = cached.healthy
|
||||||
|
? "healthy"
|
||||||
|
: cached.hasProbeTrackers
|
||||||
|
? "unhealthy"
|
||||||
|
: "unknown";
|
||||||
|
setBadge(card, cachedState, cached);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBadge(card, "checking");
|
||||||
|
|
||||||
|
const probePromise = queueProbe(magnet, infoHash);
|
||||||
|
|
||||||
|
pendingByCard.set(card, probePromise);
|
||||||
|
|
||||||
|
probePromise
|
||||||
|
.then((result) => {
|
||||||
|
pendingByCard.delete(card);
|
||||||
|
if (!card.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result) {
|
||||||
|
setBadge(card, "unknown");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result.hasProbeTrackers) {
|
||||||
|
setBadge(card, "unknown", result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.healthy) {
|
||||||
|
setBadge(card, "healthy", result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBadge(card, "unhealthy", result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.warn("[gridHealth] probe failed", err);
|
||||||
|
pendingByCard.delete(card);
|
||||||
|
if (!card.isConnected) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBadge(card, "unhealthy");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function attachHealthBadges(container) {
|
||||||
|
if (!(container instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = ensureState(container);
|
||||||
|
const cards = container.querySelectorAll(".video-card");
|
||||||
|
cards.forEach((card) => {
|
||||||
|
if (!(card instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.observedCards.has(card)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.observedCards.add(card);
|
||||||
|
state.observer.observe(card);
|
||||||
|
if (!card.dataset.magnet) {
|
||||||
|
setBadge(card, "unknown");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function refreshHealthBadges(container) {
|
||||||
|
if (!(container instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const state = containerState.get(container);
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.observer.takeRecords().forEach((entry) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
handleCardVisible({ card: entry.target, pendingByCard: state.pendingByCard });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import PQueue from "https://esm.sh/[email protected]";
|
||||||
|
import { trackerPing } from "./trackerPing.js";
|
||||||
|
import { infoHashFromMagnet } from "./magnets.js";
|
||||||
|
import { HEALTH_TTL_MS, CONCURRENCY } from "./trackerConfig.js";
|
||||||
|
|
||||||
|
const queue = new PQueue({ concurrency: CONCURRENCY });
|
||||||
|
const cache = new Map();
|
||||||
|
const inflight = new Map();
|
||||||
|
|
||||||
|
export function getDefaultHealth() {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
seeders: 0,
|
||||||
|
leechers: 0,
|
||||||
|
responded: false,
|
||||||
|
from: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHealthCached(infoHash) {
|
||||||
|
if (!infoHash) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const entry = cache.get(infoHash);
|
||||||
|
if (!entry) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (Date.now() - entry.ts > HEALTH_TTL_MS) {
|
||||||
|
cache.delete(infoHash);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHealthCache(infoHash, value) {
|
||||||
|
if (!infoHash) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cache.set(infoHash, { ts: Date.now(), value });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function queueHealthCheck(magnet, onResult) {
|
||||||
|
const infoHash = infoHashFromMagnet(magnet);
|
||||||
|
if (!infoHash) {
|
||||||
|
const fallback = getDefaultHealth();
|
||||||
|
if (typeof onResult === "function") {
|
||||||
|
onResult(fallback);
|
||||||
|
}
|
||||||
|
return Promise.resolve(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = getHealthCached(infoHash);
|
||||||
|
if (cached) {
|
||||||
|
if (typeof onResult === "function") {
|
||||||
|
onResult(cached);
|
||||||
|
}
|
||||||
|
return Promise.resolve(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inflight.has(infoHash)) {
|
||||||
|
const pending = inflight.get(infoHash);
|
||||||
|
if (typeof onResult === "function") {
|
||||||
|
pending.then(onResult).catch(() => {});
|
||||||
|
}
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobPromise = queue
|
||||||
|
.add(async () => {
|
||||||
|
try {
|
||||||
|
const health = await trackerPing(magnet);
|
||||||
|
setHealthCache(infoHash, health);
|
||||||
|
return health;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("trackerPing failed", err);
|
||||||
|
return getDefaultHealth();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inflight.delete(infoHash);
|
||||||
|
});
|
||||||
|
|
||||||
|
inflight.set(infoHash, jobPromise);
|
||||||
|
if (typeof onResult === "function") {
|
||||||
|
jobPromise.then(onResult).catch(() => {});
|
||||||
|
}
|
||||||
|
return jobPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function purgeHealthCache() {
|
||||||
|
const now = Date.now();
|
||||||
|
Array.from(cache.keys()).forEach((infoHash) => {
|
||||||
|
const entry = cache.get(infoHash);
|
||||||
|
if (!entry || now - entry.ts > HEALTH_TTL_MS) {
|
||||||
|
cache.delete(infoHash);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
+78
-10
@@ -1,5 +1,64 @@
|
|||||||
// js/index.js
|
// js/index.js
|
||||||
|
|
||||||
|
import "./bufferPolyfill.js";
|
||||||
|
import { trackPageView } from "./analytics.js";
|
||||||
|
|
||||||
|
const INTERFACE_FADE_IN_ANIMATION = "interface-fade-in";
|
||||||
|
const VIDEO_THUMBNAIL_FADE_IN_ANIMATION = "video-thumbnail-fade-in";
|
||||||
|
|
||||||
|
//
|
||||||
|
// Centralized animation cleanup
|
||||||
|
// ------------------------------
|
||||||
|
// We attach a single capture-phase listener for animation events so that any
|
||||||
|
// chrome element or thumbnail using our fade-in helpers can automatically shed
|
||||||
|
// the temporary classes/data attributes that trigger the transition. This is
|
||||||
|
// critical because these DOM nodes frequently persist while the surrounding
|
||||||
|
// lists re-render; if the classes stick around, future layout shuffles would
|
||||||
|
// replay the fade and make the UI flicker. Clearing the hooks immediately after
|
||||||
|
// the first animation keeps the graceful entrance without introducing future
|
||||||
|
// flashes.
|
||||||
|
const handleFadeInAnimationComplete = (event) => {
|
||||||
|
const { animationName, target } = event;
|
||||||
|
|
||||||
|
if (!(target instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationName === INTERFACE_FADE_IN_ANIMATION) {
|
||||||
|
if (!target.classList.contains("fade-in")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the transient fade-in class so future renders keep the element
|
||||||
|
// visible. The delays piggyback on `fade-in`, so we strip those too.
|
||||||
|
target.classList.remove("fade-in");
|
||||||
|
Array.from(target.classList).forEach((className) => {
|
||||||
|
if (className.startsWith("fade-in-delay-")) {
|
||||||
|
target.classList.remove(className);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (animationName !== VIDEO_THUMBNAIL_FADE_IN_ANIMATION) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
target.dataset &&
|
||||||
|
target.dataset.videoThumbnail === "true" &&
|
||||||
|
target.dataset.thumbnailLoaded === "true"
|
||||||
|
) {
|
||||||
|
// Thumbnails use the same pattern: once the fade finishes we clear the flag
|
||||||
|
// so a later `load` event (or template reuse) does not restart the
|
||||||
|
// animation.
|
||||||
|
delete target.dataset.thumbnailLoaded;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("animationend", handleFadeInAnimationComplete, true);
|
||||||
|
document.addEventListener("animationcancel", handleFadeInAnimationComplete, true);
|
||||||
|
|
||||||
// 1) Load modals (login, application, etc.)
|
// 1) Load modals (login, application, etc.)
|
||||||
async function loadModal(url) {
|
async function loadModal(url) {
|
||||||
try {
|
try {
|
||||||
@@ -8,9 +67,14 @@ async function loadModal(url) {
|
|||||||
throw new Error("Failed to load " + url);
|
throw new Error("Failed to load " + url);
|
||||||
}
|
}
|
||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
// Remove analytics loader tags from modal partials to avoid duplicate pageview events.
|
||||||
|
const sanitizedHtml = html.replace(
|
||||||
|
/<script\b[^>]*src=["'][^"']*tracking\.js[^"']*["'][^>]*>\s*<\/script>/gi,
|
||||||
|
""
|
||||||
|
);
|
||||||
document
|
document
|
||||||
.getElementById("modalContainer")
|
.getElementById("modalContainer")
|
||||||
.insertAdjacentHTML("beforeend", html);
|
.insertAdjacentHTML("beforeend", sanitizedHtml);
|
||||||
console.log(url, "loaded");
|
console.log(url, "loaded");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -171,6 +235,12 @@ Promise.all([
|
|||||||
|
|
||||||
// Also run once on initial load
|
// Also run once on initial load
|
||||||
handleHashChange();
|
handleHashChange();
|
||||||
|
|
||||||
|
return import("./disclaimer.js");
|
||||||
|
})
|
||||||
|
.then(({ default: disclaimerModal }) => {
|
||||||
|
disclaimerModal.init();
|
||||||
|
disclaimerModal.show();
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------------------------
|
/* -------------------------------------------
|
||||||
@@ -292,9 +362,6 @@ function handleQueryParams() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle #view=... in the hash and load the correct partial view.
|
|
||||||
*/
|
|
||||||
function handleHashChange() {
|
function handleHashChange() {
|
||||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||||
|
|
||||||
@@ -311,6 +378,7 @@ function handleHashChange() {
|
|||||||
if (typeof initFn === "function") {
|
if (typeof initFn === "function") {
|
||||||
initFn();
|
initFn();
|
||||||
}
|
}
|
||||||
|
recordView("most-recent-videos");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
@@ -321,11 +389,11 @@ function handleHashChange() {
|
|||||||
|
|
||||||
// Now dynamically load that partial, then call its init function
|
// Now dynamically load that partial, then call its init function
|
||||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||||
loadView(viewUrl).then(() => {
|
loadView(viewUrl).then(() => {
|
||||||
const initFn = viewInitRegistry[viewName];
|
const initFn = viewInitRegistry[viewName];
|
||||||
if (typeof initFn === "function") {
|
if (typeof initFn === "function") {
|
||||||
initFn();
|
initFn();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ const npubs = [
|
|||||||
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
|
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
|
||||||
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
|
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
|
||||||
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
|
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
|
||||||
|
"npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex
|
||||||
];
|
];
|
||||||
|
|
||||||
console.log("DEBUG: lists.js loaded, npubs:", npubs);
|
console.log("DEBUG: lists.js loaded, npubs:", npubs);
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
import { WSS_TRACKERS } from "./constants.js";
|
||||||
|
|
||||||
|
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||||
|
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||||
|
|
||||||
|
function getOriginProtocol() {
|
||||||
|
if (typeof window !== "undefined" && window.location?.protocol) {
|
||||||
|
return window.location.protocol.toLowerCase();
|
||||||
|
}
|
||||||
|
return "https:";
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeLoose(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(trimmed);
|
||||||
|
} catch (err) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeForComparison(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return value.trim().replace(/\/+$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryFormatAbsoluteUrl(candidate) {
|
||||||
|
if (typeof candidate !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
return parsed.toString();
|
||||||
|
} catch (err) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize and enrich a magnet string entered in the upload form so that it
|
||||||
|
* always produces a WebTorrent-friendly URI. The logic mirrors the behaviour of
|
||||||
|
* the legacy magnetUtils helper but returns a plain string for ease of use.
|
||||||
|
*/
|
||||||
|
export function normalizeAndAugmentMagnet(rawValue, { ws = "", xs = "" } = {}) {
|
||||||
|
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
|
if (!initial) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let working = initial;
|
||||||
|
|
||||||
|
if (HEX_INFO_HASH.test(working)) {
|
||||||
|
working = `magnet:?xt=urn:btih:${working.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
working = working.replace(ENCODED_BTih_PATTERN, (_, hash) => `xt=urn:btih:${hash}`);
|
||||||
|
|
||||||
|
if (!/^magnet:/i.test(working)) {
|
||||||
|
return working;
|
||||||
|
}
|
||||||
|
|
||||||
|
let fragment = "";
|
||||||
|
const hashIndex = working.indexOf("#");
|
||||||
|
if (hashIndex !== -1) {
|
||||||
|
fragment = working.slice(hashIndex);
|
||||||
|
working = working.slice(0, hashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, queryPart = ""] = working.split("?", 2);
|
||||||
|
const normalizedScheme = "magnet:";
|
||||||
|
const rawParams = queryPart
|
||||||
|
.split("&")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
for (const rawParam of rawParams) {
|
||||||
|
const [rawKey, rawVal = ""] = rawParam.split("=", 2);
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = rawVal.trim();
|
||||||
|
if (key.toLowerCase() === "xt" && value) {
|
||||||
|
const decoded = decodeLoose(value);
|
||||||
|
if (decoded) {
|
||||||
|
value = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.push({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
lowerKey: key.toLowerCase(),
|
||||||
|
compareValue: normalizeForComparison(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const appendUniqueParam = (key, value) => {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmedValue = value.trim();
|
||||||
|
if (!trimmedValue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
const compareValue = normalizeForComparison(trimmedValue);
|
||||||
|
if (compareValue) {
|
||||||
|
const exists = params.some(
|
||||||
|
(param) => param.lowerKey === lowerKey && param.compareValue === compareValue
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.push({
|
||||||
|
key,
|
||||||
|
value: trimmedValue,
|
||||||
|
lowerKey,
|
||||||
|
compareValue,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const tracker of WSS_TRACKERS) {
|
||||||
|
if (typeof tracker !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmedTracker = tracker.trim();
|
||||||
|
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
appendUniqueParam("tr", trimmedTracker);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedXs = typeof xs === "string" ? xs.trim() : "";
|
||||||
|
if (normalizedXs) {
|
||||||
|
appendUniqueParam("xs", normalizedXs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const originProtocol = getOriginProtocol();
|
||||||
|
const rawWs = typeof ws === "string" ? ws.trim() : "";
|
||||||
|
if (rawWs) {
|
||||||
|
const formattedWs = tryFormatAbsoluteUrl(rawWs);
|
||||||
|
const lowerWs = formattedWs.toLowerCase();
|
||||||
|
const isHttpSeed = lowerWs.startsWith("http://");
|
||||||
|
const allowHttpSeed = originProtocol === "http:";
|
||||||
|
if (!isHttpSeed || allowHttpSeed) {
|
||||||
|
appendUniqueParam("ws", formattedWs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params
|
||||||
|
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
return `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
// js/magnetUtils.js
|
||||||
|
|
||||||
|
import { WSS_TRACKERS } from "./constants.js";
|
||||||
|
|
||||||
|
export { WSS_TRACKERS };
|
||||||
|
|
||||||
|
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||||
|
const BTIH_PREFIX = "urn:btih:";
|
||||||
|
const ENCODED_BTih_PATTERN = /xt=urn%3Abtih%3A([0-9a-z]+)/gi;
|
||||||
|
|
||||||
|
function normalizeForComparison(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
const pathname = parsed.pathname.replace(/\/?$/, "");
|
||||||
|
const normalizedPath = pathname ? pathname : "";
|
||||||
|
return (
|
||||||
|
`${parsed.protocol}//${parsed.host}${normalizedPath}${parsed.search}${parsed.hash}`
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return trimmed.replace(/\/?$/, "").toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHttpUrl(candidate) {
|
||||||
|
if (typeof candidate !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = candidate.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = new URL(trimmed);
|
||||||
|
if (parsed.protocol === "http:" || parsed.protocol === "https:") {
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function safeDecodeMagnet(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = value.trim();
|
||||||
|
if (!decoded) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < 2; i += 1) {
|
||||||
|
if (!decoded.includes("%")) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = decodeURIComponent(decoded);
|
||||||
|
if (!candidate) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (candidate === decoded) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
decoded = candidate.trim();
|
||||||
|
} catch (err) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a magnet URI while preserving legacy payload quirks and augmenting it
|
||||||
|
* for browser playback.
|
||||||
|
*
|
||||||
|
* Key behaviors:
|
||||||
|
* - Accepts bare info-hash strings and promotes them to full `magnet:?xt=urn:btih:` links.
|
||||||
|
* - Leaves the existing `xt` payload untouched—no percent re-encoding or normalization
|
||||||
|
* beyond decoding legacy `%3A` segments.
|
||||||
|
* - Only appends browser-safe WSS trackers in addition to whatever the caller provides.
|
||||||
|
* - Skips insecure `http:` web seeds unless the application itself is running over HTTP.
|
||||||
|
*
|
||||||
|
* Note: `didChange` may be reported even if the resulting magnet string is textually
|
||||||
|
* identical to the input. Callers should not rely on `didChange` to detect a modified
|
||||||
|
* URI.
|
||||||
|
*/
|
||||||
|
export function normalizeAndAugmentMagnet(
|
||||||
|
rawValue,
|
||||||
|
{
|
||||||
|
webSeed,
|
||||||
|
torrentUrl,
|
||||||
|
xs,
|
||||||
|
extraTrackers = [],
|
||||||
|
logger,
|
||||||
|
appProtocol,
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
|
const log = typeof logger === "function" ? logger : () => {};
|
||||||
|
const initial = typeof rawValue === "string" ? rawValue.trim() : "";
|
||||||
|
if (!initial) {
|
||||||
|
return { magnet: "", didChange: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let working = initial;
|
||||||
|
let didMutate = false;
|
||||||
|
const bareHashMatch = HEX_INFO_HASH.test(working);
|
||||||
|
if (bareHashMatch) {
|
||||||
|
working = `magnet:?xt=${BTIH_PREFIX}${working.toLowerCase()}`;
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashIndex = working.indexOf("#");
|
||||||
|
let fragment = "";
|
||||||
|
if (hashIndex !== -1) {
|
||||||
|
fragment = working.slice(hashIndex);
|
||||||
|
working = working.slice(0, hashIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedXt = working.replace(ENCODED_BTih_PATTERN, (_, hash) => {
|
||||||
|
didMutate = true;
|
||||||
|
return `xt=${BTIH_PREFIX}${hash}`;
|
||||||
|
});
|
||||||
|
working = decodedXt;
|
||||||
|
|
||||||
|
if (!/^magnet:/i.test(working)) {
|
||||||
|
return {
|
||||||
|
magnet: working,
|
||||||
|
didChange: didMutate || working !== initial,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [schemePart, queryPart = ""] = working.split("?", 2);
|
||||||
|
const normalizedScheme = "magnet:";
|
||||||
|
if (schemePart !== normalizedScheme) {
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawParams = queryPart
|
||||||
|
.split("&")
|
||||||
|
.map((part) => part.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
const decodeLoose = (value) => {
|
||||||
|
if (typeof value !== "string" || !value) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch (err) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const rawParam of rawParams) {
|
||||||
|
const [rawKey, rawValue = ""] = rawParam.split("=", 2);
|
||||||
|
const key = rawKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let value = rawValue.trim();
|
||||||
|
if (key.toLowerCase() === "xt" && value) {
|
||||||
|
const decoded = decodeLoose(value);
|
||||||
|
if (decoded !== value) {
|
||||||
|
value = decoded;
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
params.push({
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
decoded: decodeLoose(value),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const torrentHint = typeof torrentUrl === "string" && torrentUrl.trim()
|
||||||
|
? torrentUrl
|
||||||
|
: typeof xs === "string"
|
||||||
|
? xs.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const safeTorrentHint = sanitizeHttpUrl(torrentHint);
|
||||||
|
const seedInputs = Array.isArray(webSeed)
|
||||||
|
? webSeed
|
||||||
|
: typeof webSeed === "string"
|
||||||
|
? [webSeed]
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const resolvedProtocol = typeof appProtocol === "string" && appProtocol
|
||||||
|
? appProtocol.toLowerCase()
|
||||||
|
: typeof window !== "undefined" && window.location && window.location.protocol
|
||||||
|
? window.location.protocol.toLowerCase()
|
||||||
|
: "https:";
|
||||||
|
|
||||||
|
const existingTrackers = new Set(
|
||||||
|
params
|
||||||
|
.filter((param) => param.key.toLowerCase() === "tr")
|
||||||
|
.map((param) => normalizeForComparison(param.decoded))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
|
||||||
|
const trackerCandidates = [
|
||||||
|
...WSS_TRACKERS,
|
||||||
|
...extraTrackers,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const tracker of trackerCandidates) {
|
||||||
|
if (typeof tracker !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmedTracker = tracker.trim();
|
||||||
|
if (!trimmedTracker) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!/^wss:\/\//i.test(trimmedTracker)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const normalizedTracker = normalizeForComparison(trimmedTracker);
|
||||||
|
if (!normalizedTracker || existingTrackers.has(normalizedTracker)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.push({ key: "tr", value: trimmedTracker, decoded: trimmedTracker });
|
||||||
|
existingTrackers.add(normalizedTracker);
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (safeTorrentHint) {
|
||||||
|
const existingXs = new Set(
|
||||||
|
params
|
||||||
|
.filter((param) => param.key.toLowerCase() === "xs")
|
||||||
|
.map((param) => normalizeForComparison(param.decoded))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
const normalizedXs = normalizeForComparison(safeTorrentHint);
|
||||||
|
if (normalizedXs && !existingXs.has(normalizedXs)) {
|
||||||
|
params.push({
|
||||||
|
key: "xs",
|
||||||
|
value: safeTorrentHint,
|
||||||
|
decoded: safeTorrentHint,
|
||||||
|
});
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seedInputs.length) {
|
||||||
|
const existingWs = new Set(
|
||||||
|
params
|
||||||
|
.filter((param) => param.key.toLowerCase() === "ws")
|
||||||
|
.map((param) => normalizeForComparison(param.decoded))
|
||||||
|
.filter(Boolean)
|
||||||
|
);
|
||||||
|
for (const seedInput of seedInputs) {
|
||||||
|
if (typeof seedInput !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const trimmedSeed = seedInput.trim();
|
||||||
|
if (!trimmedSeed) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsedSeed = new URL(trimmedSeed);
|
||||||
|
const seedProtocol = parsedSeed.protocol;
|
||||||
|
const allowHttpSeed = resolvedProtocol === "http:";
|
||||||
|
if (
|
||||||
|
seedProtocol === "https:" ||
|
||||||
|
(seedProtocol === "http:" && allowHttpSeed)
|
||||||
|
) {
|
||||||
|
const seedValue = parsedSeed.toString();
|
||||||
|
const normalizedSeed = normalizeForComparison(seedValue);
|
||||||
|
if (normalizedSeed && !existingWs.has(normalizedSeed)) {
|
||||||
|
params.push({ key: "ws", value: seedValue, decoded: seedValue });
|
||||||
|
existingWs.add(normalizedSeed);
|
||||||
|
didMutate = true;
|
||||||
|
}
|
||||||
|
} else if (seedProtocol === "http:") {
|
||||||
|
log(
|
||||||
|
`[normalizeAndAugmentMagnet] Skipping insecure web seed: ${trimmedSeed}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Ignore invalid web seed values silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params
|
||||||
|
.map(({ key, value }) => (value ? `${key}=${value}` : key))
|
||||||
|
.join("&");
|
||||||
|
|
||||||
|
const finalMagnet = `${normalizedScheme}${queryString ? `?${queryString}` : ""}${fragment}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
magnet: finalMagnet,
|
||||||
|
didChange: didMutate || finalMagnet !== initial,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import parseMagnet from "https://esm.sh/[email protected]";
|
||||||
|
|
||||||
|
function normalizeInfoHash(candidate) {
|
||||||
|
const trimmed = typeof candidate === "string" ? candidate.trim() : "";
|
||||||
|
if (!trimmed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (/^[0-9a-f]{40}$/i.test(trimmed)) {
|
||||||
|
return trimmed.toLowerCase();
|
||||||
|
}
|
||||||
|
if (/^[a-z2-7]{32}$/i.test(trimmed)) {
|
||||||
|
try {
|
||||||
|
const parsed = parseMagnet(`magnet:?xt=urn:btih:${trimmed}`);
|
||||||
|
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||||
|
return hash ? hash.toLowerCase() : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to normalize base32 info hash", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function infoHashFromMagnet(magnet) {
|
||||||
|
if (typeof magnet !== "string") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const direct = normalizeInfoHash(magnet);
|
||||||
|
if (direct) {
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = parseMagnet(magnet);
|
||||||
|
const hash = typeof parsed.infoHash === "string" ? parsed.infoHash : "";
|
||||||
|
return hash ? hash.toLowerCase() : null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to parse magnet for info hash", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trackersFromMagnet(magnet) {
|
||||||
|
if (typeof magnet !== "string") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = parseMagnet(magnet);
|
||||||
|
if (!parsed || !Array.isArray(parsed.announce)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const deduped = new Set();
|
||||||
|
parsed.announce.forEach((url) => {
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deduped.add(trimmed);
|
||||||
|
});
|
||||||
|
return Array.from(deduped);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Failed to parse magnet trackers", err);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
+518
-58
@@ -1,7 +1,10 @@
|
|||||||
// js/nostr.js
|
// js/nostr.js
|
||||||
|
|
||||||
import { isDevMode } from "./config.js";
|
import { isDevMode } from "./config.js";
|
||||||
|
import { ACCEPT_LEGACY_V1 } from "./constants.js";
|
||||||
import { accessControl } from "./accessControl.js";
|
import { accessControl } from "./accessControl.js";
|
||||||
|
// 🔧 merged conflicting changes from codex/update-video-publishing-and-parsing-logic vs unstable
|
||||||
|
import { deriveTitleFromEvent, magnetFromText } from "./videoEventUtils.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The usual relays
|
* The usual relays
|
||||||
@@ -14,6 +17,10 @@ const RELAY_URLS = [
|
|||||||
"wss://relay.nostr.band",
|
"wss://relay.nostr.band",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
||||||
|
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
|
||||||
|
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||||
|
|
||||||
// To limit error spam
|
// To limit error spam
|
||||||
let errorLogCount = 0;
|
let errorLogCount = 0;
|
||||||
const MAX_ERROR_LOGS = 100;
|
const MAX_ERROR_LOGS = 100;
|
||||||
@@ -43,53 +50,256 @@ function fakeDecrypt(encrypted) {
|
|||||||
return encrypted.split("").reverse().join("");
|
return encrypted.split("").reverse().join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
const EXTENSION_MIME_MAP = {
|
||||||
* Convert a raw Nostr event => your "video" object.
|
mp4: "video/mp4",
|
||||||
* CHANGED: skip if version <2
|
m4v: "video/x-m4v",
|
||||||
*/
|
webm: "video/webm",
|
||||||
function convertEventToVideo(event) {
|
mkv: "video/x-matroska",
|
||||||
|
mov: "video/quicktime",
|
||||||
|
avi: "video/x-msvideo",
|
||||||
|
ogv: "video/ogg",
|
||||||
|
ogg: "video/ogg",
|
||||||
|
m3u8: "application/x-mpegURL",
|
||||||
|
mpd: "application/dash+xml",
|
||||||
|
ts: "video/mp2t",
|
||||||
|
mpg: "video/mpeg",
|
||||||
|
mpeg: "video/mpeg",
|
||||||
|
flv: "video/x-flv",
|
||||||
|
"3gp": "video/3gpp",
|
||||||
|
};
|
||||||
|
|
||||||
|
function inferMimeTypeFromUrl(url) {
|
||||||
|
if (!url || typeof url !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
let pathname = "";
|
||||||
try {
|
try {
|
||||||
const content = JSON.parse(event.content || "{}");
|
const parsed = new URL(url);
|
||||||
|
pathname = parsed.pathname || "";
|
||||||
|
} catch (err) {
|
||||||
|
const sanitized = url.split("?")[0].split("#")[0];
|
||||||
|
pathname = sanitized || "";
|
||||||
|
}
|
||||||
|
|
||||||
// Example checks:
|
const lastSegment = pathname.split("/").pop() || "";
|
||||||
const isSupportedVersion = content.version >= 2;
|
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
|
||||||
const hasRequiredFields = !!(content.title && content.magnet);
|
if (!match) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
if (!isSupportedVersion) {
|
const extension = match[1].toLowerCase();
|
||||||
return {
|
return EXTENSION_MIME_MAP[extension] || "";
|
||||||
id: event.id,
|
}
|
||||||
invalid: true,
|
|
||||||
reason: "version <2",
|
/**
|
||||||
};
|
* Convert a raw Nostr event into Bitvid's canonical "video" object.
|
||||||
|
*
|
||||||
|
* The converter intentionally centralises all of the quirky legacy handling so
|
||||||
|
* that feed rendering, subscriptions, and deep links rely on the exact same
|
||||||
|
* rules. Any future regression around magnet-only posts or malformed JSON
|
||||||
|
* should be solved by updating this function (and its tests) instead of
|
||||||
|
* sprinkling ad-hoc checks elsewhere in the UI.
|
||||||
|
*
|
||||||
|
* Also accepts legacy (<v2) payloads when ACCEPT_LEGACY_V1 allows it.
|
||||||
|
*/
|
||||||
|
function convertEventToVideo(event = {}) {
|
||||||
|
const safeTrim = (value) => (typeof value === "string" ? value.trim() : "");
|
||||||
|
|
||||||
|
const rawContent = typeof event.content === "string" ? event.content : "";
|
||||||
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||||
|
|
||||||
|
let parsedContent = {};
|
||||||
|
let parseError = null;
|
||||||
|
if (rawContent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContent);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
parsedContent = parsed;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
parseError = err;
|
||||||
|
parsedContent = {};
|
||||||
}
|
}
|
||||||
if (!hasRequiredFields) {
|
}
|
||||||
return {
|
|
||||||
id: event.id,
|
const directUrl = safeTrim(parsedContent.url);
|
||||||
invalid: true,
|
const directMagnetRaw = safeTrim(parsedContent.magnet);
|
||||||
reason: "missing title/magnet",
|
|
||||||
};
|
const normalizeMagnetCandidate = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (trimmed.toLowerCase().startsWith("magnet:?")) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
const extracted = magnetFromText(trimmed);
|
||||||
|
return extracted ? extracted.trim() : "";
|
||||||
|
};
|
||||||
|
|
||||||
|
let magnet = normalizeMagnetCandidate(directMagnetRaw);
|
||||||
|
let rawMagnet = magnet ? directMagnetRaw : "";
|
||||||
|
|
||||||
|
if (!magnet && ACCEPT_LEGACY_V1) {
|
||||||
|
const inlineMagnet = normalizeMagnetCandidate(rawContent);
|
||||||
|
if (inlineMagnet) {
|
||||||
|
magnet = inlineMagnet;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!magnet) {
|
||||||
|
outer: for (const tag of tags) {
|
||||||
|
if (!Array.isArray(tag) || tag.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key =
|
||||||
|
typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||||
|
|
||||||
|
const startIndex = key === "magnet" ? 1 : 0;
|
||||||
|
for (let i = startIndex; i < tag.length; i += 1) {
|
||||||
|
const candidate = normalizeMagnetCandidate(tag[i]);
|
||||||
|
if (candidate) {
|
||||||
|
magnet = candidate;
|
||||||
|
break outer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!magnet) {
|
||||||
|
const recoveredFromRaw = magnetFromText(rawContent);
|
||||||
|
if (recoveredFromRaw) {
|
||||||
|
magnet = safeTrim(recoveredFromRaw);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rawMagnet && magnet) {
|
||||||
|
rawMagnet = magnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = directUrl;
|
||||||
|
|
||||||
|
if (!url && !magnet) {
|
||||||
|
return { id: event.id, invalid: true, reason: "missing playable source" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const thumbnail = safeTrim(parsedContent.thumbnail);
|
||||||
|
const description = safeTrim(parsedContent.description);
|
||||||
|
const rawMode = safeTrim(parsedContent.mode);
|
||||||
|
const mode = rawMode || "live";
|
||||||
|
const deleted = parsedContent.deleted === true;
|
||||||
|
const isPrivate = parsedContent.isPrivate === true;
|
||||||
|
const videoRootId = safeTrim(parsedContent.videoRootId) || event.id;
|
||||||
|
|
||||||
|
let infoHash = "";
|
||||||
|
const pushInfoHash = (candidate) => {
|
||||||
|
if (typeof candidate !== "string") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = candidate.trim().toLowerCase();
|
||||||
|
if (/^[0-9a-f]{40}$/.test(normalized)) {
|
||||||
|
infoHash = normalized;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
pushInfoHash(parsedContent.infoHash);
|
||||||
|
|
||||||
|
if (!infoHash && magnet) {
|
||||||
|
const match = magnet.match(/xt=urn:btih:([0-9a-z]+)/i);
|
||||||
|
if (match && match[1]) {
|
||||||
|
pushInfoHash(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchInfoHashInString = (value) => {
|
||||||
|
if (infoHash || typeof value !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = value.match(/[0-9a-f]{40}/i);
|
||||||
|
if (match && match[0]) {
|
||||||
|
pushInfoHash(match[0]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!infoHash && ACCEPT_LEGACY_V1) {
|
||||||
|
searchInfoHashInString(rawContent);
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (infoHash) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(tag)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < tag.length; i += 1) {
|
||||||
|
searchInfoHashInString(tag[i]);
|
||||||
|
if (infoHash) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const declaredTitle = safeTrim(parsedContent.title);
|
||||||
|
const derivedTitle = deriveTitleFromEvent({
|
||||||
|
parsedContent,
|
||||||
|
tags,
|
||||||
|
primaryTitle: declaredTitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
let title = safeTrim(derivedTitle);
|
||||||
|
if (!title && ACCEPT_LEGACY_V1 && (magnet || infoHash)) {
|
||||||
|
title = infoHash
|
||||||
|
? `Legacy Video ${infoHash.slice(0, 8)}`
|
||||||
|
: "Legacy BitTorrent Video";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!title) {
|
||||||
|
const reason = parseError
|
||||||
|
? "missing title (json parse error)"
|
||||||
|
: "missing title";
|
||||||
|
return { id: event.id, invalid: true, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawVersion = parsedContent.version;
|
||||||
|
let version = rawVersion === undefined ? 2 : Number(rawVersion);
|
||||||
|
if (!Number.isFinite(version)) {
|
||||||
|
version = rawVersion === undefined ? 2 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version < 2 && !ACCEPT_LEGACY_V1) {
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
videoRootId: content.videoRootId || event.id,
|
invalid: true,
|
||||||
version: content.version,
|
reason: `unsupported version ${version}`,
|
||||||
isPrivate: content.isPrivate ?? false,
|
|
||||||
title: content.title ?? "",
|
|
||||||
magnet: content.magnet ?? "",
|
|
||||||
thumbnail: content.thumbnail ?? "",
|
|
||||||
description: content.description ?? "",
|
|
||||||
mode: content.mode ?? "live",
|
|
||||||
deleted: content.deleted === true,
|
|
||||||
pubkey: event.pubkey,
|
|
||||||
created_at: event.created_at,
|
|
||||||
tags: event.tags,
|
|
||||||
invalid: false,
|
|
||||||
};
|
};
|
||||||
} catch (err) {
|
|
||||||
// JSON parse error
|
|
||||||
return { id: event.id, invalid: true, reason: "json parse error" };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: event.id,
|
||||||
|
videoRootId,
|
||||||
|
version,
|
||||||
|
isPrivate,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
magnet,
|
||||||
|
rawMagnet,
|
||||||
|
infoHash,
|
||||||
|
thumbnail,
|
||||||
|
description,
|
||||||
|
mode,
|
||||||
|
deleted,
|
||||||
|
pubkey: event.pubkey,
|
||||||
|
created_at: event.created_at,
|
||||||
|
tags,
|
||||||
|
invalid: false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -107,6 +317,8 @@ function getActiveKey(video) {
|
|||||||
return `LEGACY:${video.id}`;
|
return `LEGACY:${video.id}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { convertEventToVideo };
|
||||||
|
|
||||||
class NostrClient {
|
class NostrClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pool = null;
|
this.pool = null;
|
||||||
@@ -118,6 +330,107 @@ class NostrClient {
|
|||||||
|
|
||||||
// “activeMap” holds only the newest version for each root
|
// “activeMap” holds only the newest version for each root
|
||||||
this.activeMap = new Map();
|
this.activeMap = new Map();
|
||||||
|
|
||||||
|
this.hasRestoredLocalData = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
restoreLocalData() {
|
||||||
|
if (this.hasRestoredLocalData) {
|
||||||
|
return this.allEvents.size > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasRestoredLocalData = true;
|
||||||
|
|
||||||
|
if (typeof localStorage === "undefined") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const parsePayload = (raw) => {
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn("[nostr] Failed to parse cached events:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
let payload = parsePayload(localStorage.getItem(EVENTS_CACHE_STORAGE_KEY));
|
||||||
|
|
||||||
|
if (!payload) {
|
||||||
|
const legacyRaw = localStorage.getItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||||
|
const legacyParsed = parsePayload(legacyRaw);
|
||||||
|
if (legacyParsed) {
|
||||||
|
payload = {
|
||||||
|
version: 1,
|
||||||
|
savedAt: now,
|
||||||
|
events: legacyParsed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (legacyRaw) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn("[nostr] Failed to remove legacy cache:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!payload || payload.version !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof payload.savedAt !== "number" ||
|
||||||
|
payload.savedAt <= 0 ||
|
||||||
|
now - payload.savedAt > EVENTS_CACHE_TTL_MS
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(EVENTS_CACHE_STORAGE_KEY);
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn("[nostr] Failed to clear expired cache:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = payload.events;
|
||||||
|
if (!events || typeof events !== "object") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.allEvents.clear();
|
||||||
|
this.activeMap.clear();
|
||||||
|
|
||||||
|
for (const [id, video] of Object.entries(events)) {
|
||||||
|
if (!id || !video || typeof video !== "object") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.allEvents.set(id, video);
|
||||||
|
if (video.deleted) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeKey = getActiveKey(video);
|
||||||
|
const existing = this.activeMap.get(activeKey);
|
||||||
|
if (!existing || video.created_at > existing.created_at) {
|
||||||
|
this.activeMap.set(activeKey, video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.allEvents.size > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,6 +439,8 @@ class NostrClient {
|
|||||||
async init() {
|
async init() {
|
||||||
if (isDevMode) console.log("Connecting to relays...");
|
if (isDevMode) console.log("Connecting to relays...");
|
||||||
|
|
||||||
|
this.restoreLocalData();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
this.pool = new window.NostrTools.SimplePool();
|
this.pool = new window.NostrTools.SimplePool();
|
||||||
const results = await this.connectToRelays();
|
const results = await this.connectToRelays();
|
||||||
@@ -144,6 +459,11 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// We subscribe to kind `0` purely as a liveness probe because almost every
|
||||||
|
// relay can answer it quickly. Either an `event` or `eose` signals success,
|
||||||
|
// while the 5s timer guards against relays that never respond. We immediately
|
||||||
|
// `unsub` to avoid leaking subscriptions. Note: any future change must still
|
||||||
|
// provide a lightweight readiness check with similar timeout semantics.
|
||||||
async connectToRelays() {
|
async connectToRelays() {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
this.relays.map(
|
this.relays.map(
|
||||||
@@ -212,8 +532,7 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Publish a new video
|
* Publish a new video using the v3 content schema.
|
||||||
* CHANGED: Force version=2 for all new notes
|
|
||||||
*/
|
*/
|
||||||
async publishVideo(videoData, pubkey) {
|
async publishVideo(videoData, pubkey) {
|
||||||
if (!pubkey) throw new Error("Not logged in to publish video.");
|
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||||
@@ -222,31 +541,49 @@ class NostrClient {
|
|||||||
console.log("Publishing new video with data:", videoData);
|
console.log("Publishing new video with data:", videoData);
|
||||||
}
|
}
|
||||||
|
|
||||||
let finalMagnet = videoData.magnet;
|
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
|
||||||
if (videoData.isPrivate) {
|
let finalMagnet = rawMagnet.trim();
|
||||||
|
if (videoData.isPrivate && finalMagnet) {
|
||||||
finalMagnet = fakeEncrypt(finalMagnet);
|
finalMagnet = fakeEncrypt(finalMagnet);
|
||||||
}
|
}
|
||||||
|
const finalUrl =
|
||||||
|
typeof videoData.url === "string" ? videoData.url.trim() : "";
|
||||||
|
const finalThumbnail =
|
||||||
|
typeof videoData.thumbnail === "string" ? videoData.thumbnail.trim() : "";
|
||||||
|
const finalDescription =
|
||||||
|
typeof videoData.description === "string"
|
||||||
|
? videoData.description.trim()
|
||||||
|
: "";
|
||||||
|
const finalTitle =
|
||||||
|
typeof videoData.title === "string" ? videoData.title.trim() : "";
|
||||||
|
const providedMimeType =
|
||||||
|
typeof videoData.mimeType === "string"
|
||||||
|
? videoData.mimeType.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const createdAt = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
// brand-new root & d
|
// brand-new root & d
|
||||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||||
|
|
||||||
const contentObject = {
|
const contentObject = {
|
||||||
|
version: 3,
|
||||||
|
title: finalTitle,
|
||||||
|
url: finalUrl,
|
||||||
|
magnet: finalMagnet,
|
||||||
|
thumbnail: finalThumbnail,
|
||||||
|
description: finalDescription,
|
||||||
|
mode: videoData.mode || "live",
|
||||||
videoRootId,
|
videoRootId,
|
||||||
version: 2, // forcibly set version=2
|
|
||||||
deleted: false,
|
deleted: false,
|
||||||
isPrivate: videoData.isPrivate ?? false,
|
isPrivate: videoData.isPrivate ?? false,
|
||||||
title: videoData.title || "",
|
|
||||||
magnet: finalMagnet,
|
|
||||||
thumbnail: videoData.thumbnail || "",
|
|
||||||
description: videoData.description || "",
|
|
||||||
mode: videoData.mode || "live",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: createdAt,
|
||||||
tags: [
|
tags: [
|
||||||
["t", "video"],
|
["t", "video"],
|
||||||
["d", dTagValue],
|
["d", dTagValue],
|
||||||
@@ -273,6 +610,83 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (finalUrl) {
|
||||||
|
const inferredMimeType = inferMimeTypeFromUrl(finalUrl);
|
||||||
|
const mimeType =
|
||||||
|
providedMimeType || inferredMimeType || "application/octet-stream";
|
||||||
|
|
||||||
|
const mirrorTags = [
|
||||||
|
["url", finalUrl],
|
||||||
|
["m", mimeType],
|
||||||
|
];
|
||||||
|
|
||||||
|
if (finalThumbnail) {
|
||||||
|
mirrorTags.push(["thumb", finalThumbnail]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const altText = finalDescription || finalTitle || "";
|
||||||
|
if (altText) {
|
||||||
|
mirrorTags.push(["alt", altText]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!contentObject.isPrivate && finalMagnet) {
|
||||||
|
mirrorTags.push(["magnet", finalMagnet]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mirrorEvent = {
|
||||||
|
kind: 1063,
|
||||||
|
pubkey,
|
||||||
|
created_at: createdAt,
|
||||||
|
tags: mirrorTags,
|
||||||
|
content: altText,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log("Prepared NIP-94 mirror event:", mirrorEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const signedMirrorEvent = await window.nostr.signEvent(mirrorEvent);
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log("Signed NIP-94 mirror event:", signedMirrorEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
this.relays.map(async (url) => {
|
||||||
|
try {
|
||||||
|
await this.pool.publish([url], signedMirrorEvent);
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(`NIP-94 mirror published to ${url}`);
|
||||||
|
}
|
||||||
|
} catch (mirrorErr) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.error(
|
||||||
|
`Failed to publish NIP-94 mirror to ${url}`,
|
||||||
|
mirrorErr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isDevMode) {
|
||||||
|
console.log(
|
||||||
|
"NIP-94 mirror dispatched for hosted URL:",
|
||||||
|
finalUrl
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (mirrorError) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.error(
|
||||||
|
"Failed to sign/publish NIP-94 mirror event:",
|
||||||
|
mirrorError
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isDevMode) {
|
||||||
|
console.log("Skipping NIP-94 mirror: no hosted URL provided.");
|
||||||
|
}
|
||||||
return signedEvent;
|
return signedEvent;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDevMode) console.error("Failed to sign/publish:", err);
|
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||||
@@ -322,14 +736,23 @@ class NostrClient {
|
|||||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const oldUrl = baseEvent.url || "";
|
||||||
|
|
||||||
// Determine if the updated note should be private
|
// Determine if the updated note should be private
|
||||||
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
||||||
|
|
||||||
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
||||||
let finalPlainMagnet = (updatedData.magnet || "").trim() || oldPlainMagnet;
|
const newMagnetValue =
|
||||||
let finalMagnet = wantPrivate
|
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
||||||
? fakeEncrypt(finalPlainMagnet)
|
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
|
||||||
: finalPlainMagnet;
|
let finalMagnet =
|
||||||
|
wantPrivate && finalPlainMagnet
|
||||||
|
? fakeEncrypt(finalPlainMagnet)
|
||||||
|
: finalPlainMagnet;
|
||||||
|
|
||||||
|
const newUrlValue =
|
||||||
|
typeof updatedData.url === "string" ? updatedData.url.trim() : "";
|
||||||
|
const finalUrl = newUrlValue || oldUrl;
|
||||||
|
|
||||||
// Use the existing videoRootId (or fall back to the base event's ID)
|
// Use the existing videoRootId (or fall back to the base event's ID)
|
||||||
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
||||||
@@ -344,6 +767,7 @@ class NostrClient {
|
|||||||
deleted: false,
|
deleted: false,
|
||||||
isPrivate: wantPrivate,
|
isPrivate: wantPrivate,
|
||||||
title: updatedData.title ?? baseEvent.title,
|
title: updatedData.title ?? baseEvent.title,
|
||||||
|
url: finalUrl,
|
||||||
magnet: finalMagnet,
|
magnet: finalMagnet,
|
||||||
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
||||||
description: updatedData.description ?? baseEvent.description,
|
description: updatedData.description ?? baseEvent.description,
|
||||||
@@ -420,6 +844,7 @@ class NostrClient {
|
|||||||
deleted: fetched.deleted,
|
deleted: fetched.deleted,
|
||||||
isPrivate: fetched.isPrivate,
|
isPrivate: fetched.isPrivate,
|
||||||
title: fetched.title,
|
title: fetched.title,
|
||||||
|
url: fetched.url,
|
||||||
magnet: fetched.magnet,
|
magnet: fetched.magnet,
|
||||||
thumbnail: fetched.thumbnail,
|
thumbnail: fetched.thumbnail,
|
||||||
description: fetched.description,
|
description: fetched.description,
|
||||||
@@ -451,6 +876,7 @@ class NostrClient {
|
|||||||
deleted: true,
|
deleted: true,
|
||||||
isPrivate: oldContent.isPrivate ?? false,
|
isPrivate: oldContent.isPrivate ?? false,
|
||||||
title: oldContent.title || "",
|
title: oldContent.title || "",
|
||||||
|
url: "",
|
||||||
magnet: "",
|
magnet: "",
|
||||||
thumbnail: "",
|
thumbnail: "",
|
||||||
description: "This version was reverted by the creator.",
|
description: "This version was reverted by the creator.",
|
||||||
@@ -530,6 +956,7 @@ class NostrClient {
|
|||||||
deleted: vid.deleted,
|
deleted: vid.deleted,
|
||||||
isPrivate: vid.isPrivate,
|
isPrivate: vid.isPrivate,
|
||||||
title: vid.title,
|
title: vid.title,
|
||||||
|
url: vid.url,
|
||||||
magnet: vid.magnet,
|
magnet: vid.magnet,
|
||||||
thumbnail: vid.thumbnail,
|
thumbnail: vid.thumbnail,
|
||||||
description: vid.description,
|
description: vid.description,
|
||||||
@@ -548,12 +975,28 @@ class NostrClient {
|
|||||||
* Saves all known events to localStorage (or a different storage if you prefer).
|
* Saves all known events to localStorage (or a different storage if you prefer).
|
||||||
*/
|
*/
|
||||||
saveLocalData() {
|
saveLocalData() {
|
||||||
// Convert our allEvents map into a plain object for JSON storage
|
if (typeof localStorage === "undefined") {
|
||||||
const allEventsObject = {};
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
events: {},
|
||||||
|
};
|
||||||
|
|
||||||
for (const [id, vid] of this.allEvents.entries()) {
|
for (const [id, vid] of this.allEvents.entries()) {
|
||||||
allEventsObject[id] = vid;
|
payload.events[id] = vid;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(EVENTS_CACHE_STORAGE_KEY, JSON.stringify(payload));
|
||||||
|
localStorage.removeItem(LEGACY_EVENTS_STORAGE_KEY);
|
||||||
|
} catch (err) {
|
||||||
|
if (isDevMode) {
|
||||||
|
console.warn("[nostr] Failed to persist events cache:", err);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
localStorage.setItem("bitvidEvents", JSON.stringify(allEventsObject));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -634,7 +1077,7 @@ class NostrClient {
|
|||||||
sub.on("eose", () => {
|
sub.on("eose", () => {
|
||||||
if (isDevMode && invalidDuringSub.length > 0) {
|
if (isDevMode && invalidDuringSub.length > 0) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`,
|
`[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`,
|
||||||
invalidDuringSub
|
invalidDuringSub
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -646,6 +1089,23 @@ class NostrClient {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Return the subscription object if you need to unsub manually later
|
// Return the subscription object if you need to unsub manually later
|
||||||
|
const originalUnsub =
|
||||||
|
typeof sub.unsub === "function" ? sub.unsub.bind(sub) : () => {};
|
||||||
|
let unsubscribed = false;
|
||||||
|
sub.unsub = () => {
|
||||||
|
if (unsubscribed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
unsubscribed = true;
|
||||||
|
clearInterval(processInterval);
|
||||||
|
try {
|
||||||
|
return originalUnsub();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[subscribeVideos] Failed to unsub from pool:", err);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +1161,7 @@ class NostrClient {
|
|||||||
// OPTIONAL: Log invalid stats
|
// OPTIONAL: Log invalid stats
|
||||||
if (invalidNotes.length > 0 && isDevMode) {
|
if (invalidNotes.length > 0 && isDevMode) {
|
||||||
console.warn(
|
console.warn(
|
||||||
`Skipped ${invalidNotes.length} invalid v2 notes:\n`,
|
`Skipped ${invalidNotes.length} invalid video notes:\n`,
|
||||||
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
|
invalidNotes.map((n) => `${n.id.slice(0, 8)}.. => ${n.reason}`)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
// js/playbackUtils.js
|
||||||
|
|
||||||
|
import {
|
||||||
|
normalizeAndAugmentMagnet,
|
||||||
|
safeDecodeMagnet,
|
||||||
|
} from "./magnetUtils.js";
|
||||||
|
|
||||||
|
const HEX_INFO_HASH = /^[0-9a-f]{40}$/i;
|
||||||
|
const MAGNET_URI = /^magnet:\?/i;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalizes torrent related playback inputs into a canonical magnet payload.
|
||||||
|
*
|
||||||
|
* The function first trims and safely decodes the incoming `magnet` string so
|
||||||
|
* that URL encoded magnets become plain text before processing. Bare info hash
|
||||||
|
* strings are tolerated: whenever the caller supplies an info hash (either via
|
||||||
|
* the `infoHash` field or a magnet that looks like one) it gets promoted to a
|
||||||
|
* full magnet URI so downstream WebTorrent code can consume it directly.
|
||||||
|
*
|
||||||
|
* When the normalized output differs from the original magnet input (for
|
||||||
|
* example due to whitespace/encoding fixes or tracker augmentation) the
|
||||||
|
* original candidate is exposed through `fallbackMagnet`. Callers can surface
|
||||||
|
* that value if a later refactor breaks magnet normalization.
|
||||||
|
*
|
||||||
|
* The returned flags communicate provenance: `provided` indicates that some
|
||||||
|
* torrent-related input was supplied, while `usedInfoHash` is only `true` when
|
||||||
|
* the normalized magnet was derived from an info hash instead of an already
|
||||||
|
* well-formed magnet URI.
|
||||||
|
*
|
||||||
|
* Web seeds coming from the `url` parameter are forwarded to
|
||||||
|
* `normalizeAndAugmentMagnet` via the `webSeed` option. Refactors must preserve
|
||||||
|
* that flow (even if the parameter name changes) so hosted URLs continue to
|
||||||
|
* populate `ws=` hints automatically.
|
||||||
|
*/
|
||||||
|
export function deriveTorrentPlaybackConfig({
|
||||||
|
magnet = "",
|
||||||
|
infoHash = "",
|
||||||
|
url = "",
|
||||||
|
logger,
|
||||||
|
appProtocol,
|
||||||
|
} = {}) {
|
||||||
|
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
|
||||||
|
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
|
||||||
|
const magnetCandidate = decodedMagnet || trimmedMagnet;
|
||||||
|
const trimmedInfoHash =
|
||||||
|
typeof infoHash === "string" ? infoHash.trim().toLowerCase() : "";
|
||||||
|
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
|
||||||
|
|
||||||
|
const magnetIsUri = MAGNET_URI.test(magnetCandidate);
|
||||||
|
const magnetLooksLikeInfoHash = HEX_INFO_HASH.test(magnetCandidate);
|
||||||
|
const resolvedInfoHash = trimmedInfoHash || (magnetLooksLikeInfoHash
|
||||||
|
? magnetCandidate.toLowerCase()
|
||||||
|
: "");
|
||||||
|
|
||||||
|
const normalizationInput = magnetIsUri ? magnetCandidate : resolvedInfoHash;
|
||||||
|
const provided = Boolean(trimmedMagnet || trimmedInfoHash);
|
||||||
|
const decodeChanged = magnetCandidate !== trimmedMagnet;
|
||||||
|
|
||||||
|
if (!normalizationInput) {
|
||||||
|
return {
|
||||||
|
magnet: "",
|
||||||
|
fallbackMagnet: "",
|
||||||
|
provided,
|
||||||
|
usedInfoHash: false,
|
||||||
|
originalInput: "",
|
||||||
|
didMutate: false,
|
||||||
|
infoHash: resolvedInfoHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalization = normalizeAndAugmentMagnet(normalizationInput, {
|
||||||
|
webSeed: sanitizedUrl ? [sanitizedUrl] : [],
|
||||||
|
logger,
|
||||||
|
appProtocol,
|
||||||
|
});
|
||||||
|
|
||||||
|
let normalizedMagnet = normalization.magnet;
|
||||||
|
if (!normalizedMagnet || !MAGNET_URI.test(normalizedMagnet)) {
|
||||||
|
if (magnetIsUri) {
|
||||||
|
normalizedMagnet = normalizationInput;
|
||||||
|
} else if (resolvedInfoHash) {
|
||||||
|
normalizedMagnet = `magnet:?xt=urn:btih:${resolvedInfoHash}`;
|
||||||
|
} else {
|
||||||
|
normalizedMagnet = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedInfoHash = !magnetIsUri && Boolean(resolvedInfoHash);
|
||||||
|
const fallbackMagnet = magnetIsUri && normalization.didChange
|
||||||
|
? normalizationInput
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
magnet: normalizedMagnet,
|
||||||
|
fallbackMagnet,
|
||||||
|
provided,
|
||||||
|
usedInfoHash,
|
||||||
|
originalInput: normalizationInput,
|
||||||
|
didMutate: normalization.didChange || usedInfoHash || decodeChanged,
|
||||||
|
infoHash: resolvedInfoHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
+160
-43
@@ -1,5 +1,36 @@
|
|||||||
// js/subscriptions.js
|
// js/subscriptions.js
|
||||||
import { nostrClient } from "./nostr.js";
|
import {
|
||||||
|
nostrClient,
|
||||||
|
convertEventToVideo as sharedConvertEventToVideo,
|
||||||
|
} from "./nostr.js";
|
||||||
|
import { attachHealthBadges } from "./gridHealth.js";
|
||||||
|
|
||||||
|
function getAbsoluteShareUrl(nevent) {
|
||||||
|
if (!nevent) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.app?.buildShareUrlFromNevent) {
|
||||||
|
const candidate = window.app.buildShareUrlFromNevent(nevent);
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = window.location?.origin || "";
|
||||||
|
const pathname = window.location?.pathname || "";
|
||||||
|
let base = origin || pathname ? `${origin}${pathname}` : "";
|
||||||
|
if (!base) {
|
||||||
|
const href = window.location?.href || "";
|
||||||
|
base = href ? href.split(/[?#]/)[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!base) {
|
||||||
|
return `?v=${encodeURIComponent(nevent)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}?v=${encodeURIComponent(nevent)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the user's subscription list (kind=30002) *privately*,
|
* Manages the user's subscription list (kind=30002) *privately*,
|
||||||
@@ -120,6 +151,14 @@ class SubscriptionsManager {
|
|||||||
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
||||||
const plainStr = JSON.stringify(plainObj);
|
const plainStr = JSON.stringify(plainObj);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The subscription list is stored as a NIP-04 message to self, so both
|
||||||
|
* encryption and decryption intentionally use the user's own pubkey.
|
||||||
|
* Extensions are expected to support this encrypt-to-self flow; altering
|
||||||
|
* the target would break loadSubscriptions, which decrypts with the same
|
||||||
|
* pubkey. Any future sharing model (e.g., sharing with another user) will
|
||||||
|
* need a parallel read path and should not overwrite this behavior.
|
||||||
|
*/
|
||||||
let cipherText = "";
|
let cipherText = "";
|
||||||
try {
|
try {
|
||||||
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
||||||
@@ -234,7 +273,12 @@ class SubscriptionsManager {
|
|||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!videos.length) {
|
const safeVideos = Array.isArray(videos) ? videos : [];
|
||||||
|
const dedupedVideos =
|
||||||
|
window.app?.dedupeVideosByRoot?.(safeVideos) ??
|
||||||
|
this.dedupeToNewestByRoot(safeVideos);
|
||||||
|
|
||||||
|
if (!dedupedVideos.length) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||||
No videos available yet.
|
No videos available yet.
|
||||||
@@ -243,25 +287,27 @@ class SubscriptionsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sort newest first
|
// Sort newest first
|
||||||
videos.sort((a, b) => b.created_at - a.created_at);
|
dedupedVideos.sort((a, b) => b.created_at - a.created_at);
|
||||||
|
|
||||||
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
const fullAllEventsArray = Array.from(nostrClient.allEvents.values());
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
// Only declare localAuthorSet once
|
// Only declare localAuthorSet once
|
||||||
const localAuthorSet = new Set();
|
const localAuthorSet = new Set();
|
||||||
|
|
||||||
videos.forEach((video, index) => {
|
dedupedVideos.forEach((video, index) => {
|
||||||
if (!video.id || !video.title) {
|
if (!video.id || !video.title) {
|
||||||
console.error("Missing ID or title:", video);
|
console.error("Missing ID or title:", video);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keep the global videos map up to date so delegated playback handlers
|
||||||
|
// can reuse the already fetched metadata for this event.
|
||||||
|
window.app?.videosMap?.set(video.id, video);
|
||||||
|
|
||||||
localAuthorSet.add(video.pubkey);
|
localAuthorSet.add(video.pubkey);
|
||||||
|
|
||||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
const shareUrl = getAbsoluteShareUrl(nevent);
|
||||||
nevent
|
|
||||||
)}`;
|
|
||||||
const canEdit = window.app?.pubkey === video.pubkey;
|
const canEdit = window.app?.pubkey === video.pubkey;
|
||||||
|
|
||||||
const highlightClass =
|
const highlightClass =
|
||||||
@@ -283,6 +329,7 @@ class SubscriptionsManager {
|
|||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||||
data-revert-index="${index}"
|
data-revert-index="${index}"
|
||||||
|
data-revert-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Revert
|
Revert
|
||||||
</button>
|
</button>
|
||||||
@@ -311,6 +358,7 @@ class SubscriptionsManager {
|
|||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
|
||||||
data-edit-index="${index}"
|
data-edit-index="${index}"
|
||||||
|
data-edit-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
@@ -318,6 +366,7 @@ class SubscriptionsManager {
|
|||||||
<button
|
<button
|
||||||
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
|
||||||
data-delete-all-index="${index}"
|
data-delete-all-index="${index}"
|
||||||
|
data-delete-all-event-id="${video.id}"
|
||||||
>
|
>
|
||||||
Delete All
|
Delete All
|
||||||
</button>
|
</button>
|
||||||
@@ -329,11 +378,43 @@ class SubscriptionsManager {
|
|||||||
|
|
||||||
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||||
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
const safeThumb = window.app?.escapeHTML(video.thumbnail) || "";
|
||||||
|
const playbackUrl =
|
||||||
|
typeof video.url === "string" ? video.url : "";
|
||||||
|
const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
|
||||||
|
const trimmedMagnet =
|
||||||
|
typeof video.magnet === "string" ? video.magnet.trim() : "";
|
||||||
|
const legacyInfoHash =
|
||||||
|
typeof video.infoHash === "string" ? video.infoHash.trim() : "";
|
||||||
|
const magnetCandidate = trimmedMagnet || legacyInfoHash;
|
||||||
|
const playbackMagnet = magnetCandidate;
|
||||||
|
const magnetProvided = magnetCandidate.length > 0;
|
||||||
|
const magnetSupported =
|
||||||
|
window.app?.isMagnetUriSupported?.(magnetCandidate) ?? false;
|
||||||
|
const urlBadgeHtml = trimmedUrl
|
||||||
|
? window.app?.getUrlHealthPlaceholderMarkup?.({ includeMargin: false }) ??
|
||||||
|
""
|
||||||
|
: "";
|
||||||
|
const torrentHealthBadgeHtml =
|
||||||
|
magnetProvided && magnetSupported
|
||||||
|
? window.app?.getTorrentHealthBadgeMarkup?.({
|
||||||
|
includeMargin: false,
|
||||||
|
}) ?? ""
|
||||||
|
: "";
|
||||||
|
const connectionBadgesHtml =
|
||||||
|
urlBadgeHtml || torrentHealthBadgeHtml
|
||||||
|
? `
|
||||||
|
<div class="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
${urlBadgeHtml}${torrentHealthBadgeHtml}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
const cardHtml = `
|
const cardHtml = `
|
||||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||||
<a
|
<a
|
||||||
href="${shareUrl}"
|
href="${shareUrl}"
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
data-video-id="${video.id}"
|
||||||
|
data-play-url=""
|
||||||
|
data-play-magnet=""
|
||||||
class="block cursor-pointer relative group"
|
class="block cursor-pointer relative group"
|
||||||
>
|
>
|
||||||
<div class="ratio-16-9">
|
<div class="ratio-16-9">
|
||||||
@@ -347,7 +428,9 @@ class SubscriptionsManager {
|
|||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<h3
|
<h3
|
||||||
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
|
||||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
data-video-id="${video.id}"
|
||||||
|
data-play-url=""
|
||||||
|
data-play-magnet=""
|
||||||
>
|
>
|
||||||
${safeTitle}
|
${safeTitle}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -375,6 +458,7 @@ class SubscriptionsManager {
|
|||||||
</div>
|
</div>
|
||||||
${gearMenu}
|
${gearMenu}
|
||||||
</div>
|
</div>
|
||||||
|
${connectionBadgesHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -382,10 +466,52 @@ class SubscriptionsManager {
|
|||||||
const t = document.createElement("template");
|
const t = document.createElement("template");
|
||||||
t.innerHTML = cardHtml.trim();
|
t.innerHTML = cardHtml.trim();
|
||||||
const cardEl = t.content.firstElementChild;
|
const cardEl = t.content.firstElementChild;
|
||||||
|
if (cardEl) {
|
||||||
|
// Leave the data-play-* attributes empty in the literal markup so we can
|
||||||
|
// assign the raw URL/magnet strings post-parsing without HTML entity
|
||||||
|
// escaping, mirroring the approach in app.js. The URL is encoded so that
|
||||||
|
// special characters survive storage in data-* attributes; the click
|
||||||
|
// handler decodes it right before playback while keeping the magnet raw.
|
||||||
|
const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
|
||||||
|
interactiveEls.forEach((el) => {
|
||||||
|
if (!el.dataset) return;
|
||||||
|
|
||||||
|
if (trimmedUrl) {
|
||||||
|
el.dataset.playUrl = encodeURIComponent(trimmedUrl);
|
||||||
|
} else {
|
||||||
|
delete el.dataset.playUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.dataset.playMagnet = playbackMagnet || "";
|
||||||
|
});
|
||||||
|
|
||||||
|
if (magnetProvided) {
|
||||||
|
cardEl.dataset.magnet = playbackMagnet;
|
||||||
|
} else if (cardEl.dataset.magnet) {
|
||||||
|
delete cardEl.dataset.magnet;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmedUrl && window.app?.handleUrlHealthBadge) {
|
||||||
|
const badgeEl = cardEl.querySelector("[data-url-health-state]");
|
||||||
|
if (badgeEl) {
|
||||||
|
window.app.handleUrlHealthBadge({
|
||||||
|
video,
|
||||||
|
url: trimmedUrl,
|
||||||
|
badgeEl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fragment.appendChild(cardEl);
|
fragment.appendChild(cardEl);
|
||||||
});
|
});
|
||||||
|
|
||||||
container.appendChild(fragment);
|
container.appendChild(fragment);
|
||||||
|
attachHealthBadges(container);
|
||||||
|
|
||||||
|
if (window.app) {
|
||||||
|
window.app.videoList = container;
|
||||||
|
window.app.attachVideoListHandler?.();
|
||||||
|
}
|
||||||
|
|
||||||
// Lazy-load
|
// Lazy-load
|
||||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||||
@@ -407,10 +533,15 @@ class SubscriptionsManager {
|
|||||||
editButtons.forEach((btn) => {
|
editButtons.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = btn.getAttribute("data-edit-index");
|
const idxAttr = btn.getAttribute("data-edit-index");
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
window.app?.handleEditVideo(idx);
|
const eventId = btn.getAttribute("data-edit-event-id") || "";
|
||||||
|
window.app?.handleEditVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -419,10 +550,15 @@ class SubscriptionsManager {
|
|||||||
revertButtons.forEach((btn) => {
|
revertButtons.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = btn.getAttribute("data-revert-index");
|
const idxAttr = btn.getAttribute("data-revert-index");
|
||||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dropdown) dropdown.classList.add("hidden");
|
if (dropdown) dropdown.classList.add("hidden");
|
||||||
window.app?.handleRevertVideo(idx);
|
const eventId = btn.getAttribute("data-revert-event-id") || "";
|
||||||
|
window.app?.handleRevertVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -433,10 +569,15 @@ class SubscriptionsManager {
|
|||||||
deleteAllButtons.forEach((btn) => {
|
deleteAllButtons.forEach((btn) => {
|
||||||
btn.addEventListener("click", (ev) => {
|
btn.addEventListener("click", (ev) => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
const idx = btn.getAttribute("data-delete-all-index");
|
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||||
const dd = document.getElementById(`settingsDropdown-${idx}`);
|
const idx = Number.parseInt(idxAttr, 10);
|
||||||
|
const dd = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||||
if (dd) dd.classList.add("hidden");
|
if (dd) dd.classList.add("hidden");
|
||||||
window.app?.handleFullDeleteVideo(idx);
|
const eventId = btn.getAttribute("data-delete-all-event-id") || "";
|
||||||
|
window.app?.handleFullDeleteVideo({
|
||||||
|
eventId,
|
||||||
|
index: Number.isNaN(idx) ? null : idx,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -480,31 +621,7 @@ class SubscriptionsManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
convertEventToVideo(evt) {
|
convertEventToVideo(evt) {
|
||||||
try {
|
return sharedConvertEventToVideo(evt);
|
||||||
const content = JSON.parse(evt.content || "{}");
|
|
||||||
const hasFields = !!(content.title && content.magnet);
|
|
||||||
const versionOk = content.version >= 2;
|
|
||||||
if (!versionOk || !hasFields) {
|
|
||||||
return { id: evt.id, invalid: true };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: evt.id,
|
|
||||||
pubkey: evt.pubkey,
|
|
||||||
created_at: evt.created_at,
|
|
||||||
videoRootId: content.videoRootId || evt.id,
|
|
||||||
version: content.version,
|
|
||||||
deleted: content.deleted === true,
|
|
||||||
isPrivate: content.isPrivate === true,
|
|
||||||
title: content.title || "",
|
|
||||||
magnet: content.magnet || "",
|
|
||||||
thumbnail: content.thumbnail || "",
|
|
||||||
description: content.description || "",
|
|
||||||
tags: evt.tags || [],
|
|
||||||
invalid: false,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
return { id: evt.id, invalid: true };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dedupeToNewestByRoot(videos) {
|
dedupeToNewestByRoot(videos) {
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { WSS_TRACKERS as DEFAULT_WSS_TRACKERS } from "./constants.js";
|
||||||
|
|
||||||
|
export const TRACKER_TIMEOUT_MS = 3000;
|
||||||
|
export const TRACKER_PER_MAGNET = 3;
|
||||||
|
export const HEALTH_TTL_MS = 2 * 60 * 1000;
|
||||||
|
export const CONCURRENCY = 3;
|
||||||
|
export const TRACKER_ERROR_COOLDOWN_MS = 60 * 1000;
|
||||||
|
|
||||||
|
export function resolveTrackerList({ magnetTrackers } = {}) {
|
||||||
|
const combined = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
const pushUnique = (url) => {
|
||||||
|
if (typeof url !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = url.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^wss:\/\//i.test(trimmed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = trimmed.toLowerCase();
|
||||||
|
if (seen.has(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(normalized);
|
||||||
|
combined.push(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (Array.isArray(magnetTrackers)) {
|
||||||
|
magnetTrackers.forEach(pushUnique);
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_WSS_TRACKERS.forEach(pushUnique);
|
||||||
|
|
||||||
|
return combined.slice(0, TRACKER_PER_MAGNET);
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import "./bufferPolyfill.js";
|
||||||
|
import Client from "https://esm.sh/[email protected]/client?bundle";
|
||||||
|
import { infoHashFromMagnet, trackersFromMagnet } from "./magnets.js";
|
||||||
|
import {
|
||||||
|
resolveTrackerList,
|
||||||
|
TRACKER_TIMEOUT_MS,
|
||||||
|
TRACKER_ERROR_COOLDOWN_MS,
|
||||||
|
} from "./trackerConfig.js";
|
||||||
|
|
||||||
|
const trackerState = new Map();
|
||||||
|
|
||||||
|
function now() {
|
||||||
|
return Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function randomPeerId() {
|
||||||
|
const bytes = new Uint8Array(20);
|
||||||
|
if (typeof crypto !== "undefined" && crypto.getRandomValues) {
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
} else {
|
||||||
|
for (let i = 0; i < bytes.length; i += 1) {
|
||||||
|
bytes[i] = Math.floor(Math.random() * 256);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultHealth() {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
seeders: 0,
|
||||||
|
leechers: 0,
|
||||||
|
responded: false,
|
||||||
|
from: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrackerEntry(url) {
|
||||||
|
const existing = trackerState.get(url);
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
const entry = {
|
||||||
|
consecutiveErrors: 0,
|
||||||
|
lastErrorAt: 0,
|
||||||
|
cooldownUntil: 0,
|
||||||
|
};
|
||||||
|
trackerState.set(url, entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTrackerSuccess(url) {
|
||||||
|
const entry = getTrackerEntry(url);
|
||||||
|
entry.consecutiveErrors = 0;
|
||||||
|
entry.cooldownUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markTrackerError(url) {
|
||||||
|
const entry = getTrackerEntry(url);
|
||||||
|
const nowTs = now();
|
||||||
|
if (entry.lastErrorAt && nowTs - entry.lastErrorAt < TRACKER_ERROR_COOLDOWN_MS) {
|
||||||
|
entry.consecutiveErrors += 1;
|
||||||
|
} else {
|
||||||
|
entry.consecutiveErrors = 1;
|
||||||
|
}
|
||||||
|
entry.lastErrorAt = nowTs;
|
||||||
|
if (entry.consecutiveErrors >= 2) {
|
||||||
|
entry.cooldownUntil = nowTs + TRACKER_ERROR_COOLDOWN_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTrackerUsable(url) {
|
||||||
|
const entry = getTrackerEntry(url);
|
||||||
|
return entry.cooldownUntil === 0 || entry.cooldownUntil <= now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function trackerPing(magnet, trackers) {
|
||||||
|
const infoHash = infoHashFromMagnet(magnet);
|
||||||
|
if (!infoHash) {
|
||||||
|
return getDefaultHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnetTrackers = trackers || trackersFromMagnet(magnet);
|
||||||
|
const announceList = resolveTrackerList({ magnetTrackers });
|
||||||
|
const usable = announceList.filter(isTrackerUsable);
|
||||||
|
const announces = usable.length ? usable : announceList;
|
||||||
|
|
||||||
|
if (!announces.length) {
|
||||||
|
return getDefaultHealth();
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerId = randomPeerId();
|
||||||
|
const result = getDefaultHealth();
|
||||||
|
const clients = new Set();
|
||||||
|
let settled = false;
|
||||||
|
let timeoutId = null;
|
||||||
|
|
||||||
|
const finalize = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
clients.forEach((client) => {
|
||||||
|
try {
|
||||||
|
client.destroy();
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
clients.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (Number.isFinite(TRACKER_TIMEOUT_MS) && TRACKER_TIMEOUT_MS > 0) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
finalize();
|
||||||
|
resolve(result);
|
||||||
|
}, TRACKER_TIMEOUT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining = announces.length;
|
||||||
|
|
||||||
|
const handleComplete = (client, url) => {
|
||||||
|
if (clients.has(client)) {
|
||||||
|
clients.delete(client);
|
||||||
|
}
|
||||||
|
if (url && result.from.includes(url) && result.ok) {
|
||||||
|
markTrackerSuccess(url);
|
||||||
|
}
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0 && !settled) {
|
||||||
|
finalize();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResult = (url, data) => {
|
||||||
|
result.responded = true;
|
||||||
|
if (url && !result.from.includes(url)) {
|
||||||
|
result.from.push(url);
|
||||||
|
}
|
||||||
|
const seeders = Number.isFinite(data?.complete)
|
||||||
|
? Number(data.complete)
|
||||||
|
: 0;
|
||||||
|
const leechers = Number.isFinite(data?.incomplete)
|
||||||
|
? Number(data.incomplete)
|
||||||
|
: 0;
|
||||||
|
if (seeders > result.seeders) {
|
||||||
|
result.seeders = seeders;
|
||||||
|
}
|
||||||
|
if (leechers > result.leechers) {
|
||||||
|
result.leechers = leechers;
|
||||||
|
}
|
||||||
|
if (seeders > 0) {
|
||||||
|
result.ok = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
announces.forEach((url) => {
|
||||||
|
let client;
|
||||||
|
try {
|
||||||
|
client = new Client({
|
||||||
|
infoHash,
|
||||||
|
peerId,
|
||||||
|
announce: [url],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
markTrackerError(url);
|
||||||
|
remaining -= 1;
|
||||||
|
if (remaining <= 0 && !settled) {
|
||||||
|
finalize();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clients.add(client);
|
||||||
|
|
||||||
|
const cleanupAndResolve = () => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finalize();
|
||||||
|
resolve(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
client.once("update", (data) => {
|
||||||
|
handleResult(url, data);
|
||||||
|
markTrackerSuccess(url);
|
||||||
|
if (result.ok) {
|
||||||
|
cleanupAndResolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleComplete(client, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once("error", () => {
|
||||||
|
markTrackerError(url);
|
||||||
|
handleComplete(client, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
client.once("warning", () => {
|
||||||
|
handleComplete(client, url);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
client.start();
|
||||||
|
} catch (err) {
|
||||||
|
markTrackerError(url);
|
||||||
|
handleComplete(client, url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (announces.length === 0) {
|
||||||
|
finalize();
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTrackerStateSnapshot() {
|
||||||
|
const snapshot = {};
|
||||||
|
trackerState.forEach((value, key) => {
|
||||||
|
snapshot[key] = { ...value };
|
||||||
|
});
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// js/tracking.js
|
||||||
|
// Centralized configuration and loader for the site's analytics script.
|
||||||
|
// Update `bitvidTrackingConfig` to change the tracking provider or settings.
|
||||||
|
window.bitvidTrackingConfig = window.bitvidTrackingConfig || {
|
||||||
|
src: "https://umami.malin.onl/script.js",
|
||||||
|
websiteId: "1f8eead2-79f0-4dba-8c3b-ed9b08b6e877",
|
||||||
|
};
|
||||||
|
|
||||||
|
(function loadTrackingScript(config) {
|
||||||
|
if (!config || !config.src) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.querySelector(`script[src="${config.src}"]`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const script = document.createElement("script");
|
||||||
|
script.src = config.src;
|
||||||
|
script.defer = true;
|
||||||
|
|
||||||
|
if (config.websiteId) {
|
||||||
|
script.setAttribute("data-website-id", config.websiteId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalAttributes = config.attributes || {};
|
||||||
|
Object.entries(additionalAttributes).forEach(([key, value]) => {
|
||||||
|
if (key === "defer") {
|
||||||
|
script.defer = Boolean(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === false || value === null || typeof value === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
script.setAttribute(key, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.head.appendChild(script);
|
||||||
|
})(window.bitvidTrackingConfig);
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// js/videoEventUtils.js
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts normalized fields from a Bitvid video event while
|
||||||
|
* tolerating legacy payloads that may omit version >= 2 metadata.
|
||||||
|
*/
|
||||||
|
const MAGNET_URI_PATTERN = /^magnet:\?/i;
|
||||||
|
const HEX_INFO_HASH_PATTERN = /\b[0-9a-f]{40}\b/gi;
|
||||||
|
const LEGACY_MAGNET_PATTERN = /magnet:\?xt=urn:btih:[0-9a-z]{40,}[^\s"'<>]*/i;
|
||||||
|
|
||||||
|
function safeTrim(value) {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFirstString(values = []) {
|
||||||
|
if (!Array.isArray(values)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = safeTrim(value);
|
||||||
|
if (trimmed) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveTitleFromEvent({
|
||||||
|
parsedContent = {},
|
||||||
|
tags = [],
|
||||||
|
primaryTitle = "",
|
||||||
|
} = {}) {
|
||||||
|
const initial = safeTrim(primaryTitle);
|
||||||
|
if (initial) {
|
||||||
|
return initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
const directCandidates = pickFirstString([
|
||||||
|
parsedContent?.title,
|
||||||
|
parsedContent?.name,
|
||||||
|
parsedContent?.filename,
|
||||||
|
parsedContent?.fileName,
|
||||||
|
parsedContent?.subject,
|
||||||
|
parsedContent?.caption,
|
||||||
|
]);
|
||||||
|
if (directCandidates) {
|
||||||
|
return directCandidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaTitle = safeTrim(parsedContent?.meta?.title);
|
||||||
|
if (metaTitle) {
|
||||||
|
return metaTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(tags)) {
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!Array.isArray(tag) || tag.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = safeTrim(tag[0]).toLowerCase();
|
||||||
|
if (!key) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (["title", "subject", "caption", "name"].includes(key)) {
|
||||||
|
const candidate = safeTrim(tag[1]);
|
||||||
|
if (candidate) {
|
||||||
|
return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractInfoHashesFromString(value, pushInfoHash) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matches = trimmed.match(HEX_INFO_HASH_PATTERN);
|
||||||
|
if (!matches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of matches) {
|
||||||
|
const normalized = match.toLowerCase();
|
||||||
|
pushInfoHash(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function traverseForInfoHashes(value, pushInfoHash) {
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
extractInfoHashesFromString(value, pushInfoHash);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (const item of value) {
|
||||||
|
traverseForInfoHashes(item, pushInfoHash);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof value === "object") {
|
||||||
|
for (const nested of Object.values(value)) {
|
||||||
|
traverseForInfoHashes(nested, pushInfoHash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseVideoEventPayload(event = {}) {
|
||||||
|
const rawContent = typeof event.content === "string" ? event.content : "";
|
||||||
|
|
||||||
|
let parsedContent = {};
|
||||||
|
let parseError = null;
|
||||||
|
if (rawContent) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawContent);
|
||||||
|
if (parsed && typeof parsed === "object") {
|
||||||
|
parsedContent = parsed;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
parseError = err;
|
||||||
|
parsedContent = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = typeof parsedContent.title === "string"
|
||||||
|
? parsedContent.title.trim()
|
||||||
|
: "";
|
||||||
|
const thumbnail = typeof parsedContent.thumbnail === "string"
|
||||||
|
? parsedContent.thumbnail.trim()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const magnetCandidates = [];
|
||||||
|
const infoHashCandidates = [];
|
||||||
|
const urlCandidates = [];
|
||||||
|
|
||||||
|
const pushUnique = (arr, value) => {
|
||||||
|
if (!value || typeof value !== "string") return;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed || arr.includes(trimmed)) return;
|
||||||
|
arr.push(trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pushInfoHash = (candidate) => {
|
||||||
|
if (!candidate) return;
|
||||||
|
const normalized = candidate.toLowerCase();
|
||||||
|
if (!/^[0-9a-f]{40}$/.test(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (infoHashCandidates.includes(normalized)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
infoHashCandidates.push(normalized);
|
||||||
|
};
|
||||||
|
|
||||||
|
const collectMagnetOrInfoHash = (value) => {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (MAGNET_URI_PATTERN.test(trimmed)) {
|
||||||
|
pushUnique(magnetCandidates, trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractInfoHashesFromString(trimmed, pushInfoHash);
|
||||||
|
|
||||||
|
const urnMatch = trimmed.match(/^urn:btih:([0-9a-z]+)$/i);
|
||||||
|
if (urnMatch) {
|
||||||
|
pushInfoHash(urnMatch[1]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof parsedContent.magnet === "string") {
|
||||||
|
collectMagnetOrInfoHash(parsedContent.magnet);
|
||||||
|
}
|
||||||
|
if (typeof parsedContent.url === "string") {
|
||||||
|
const parsedUrl = parsedContent.url.trim();
|
||||||
|
if (parsedUrl && parsedUrl !== thumbnail) {
|
||||||
|
// Keep this guard so we don't duplicate thumbnail URLs in the playable URL list.
|
||||||
|
pushUnique(urlCandidates, parsedUrl);
|
||||||
|
}
|
||||||
|
// Legacy Bitvid events sometimes embedded magnets/info-hashes in the URL field, so treat it accordingly.
|
||||||
|
collectMagnetOrInfoHash(parsedUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||||
|
const urlTagKeys = new Set(["r", "url", "u"]);
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!Array.isArray(tag) || tag.length < 2) continue;
|
||||||
|
const key = typeof tag[0] === "string" ? tag[0] : "";
|
||||||
|
const value = typeof tag[1] === "string" ? tag[1] : "";
|
||||||
|
if (!value) continue;
|
||||||
|
|
||||||
|
if (value.toLowerCase().startsWith("magnet:")) {
|
||||||
|
collectMagnetOrInfoHash(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (urlTagKeys.has(key) && /^https?:\/\//i.test(value)) {
|
||||||
|
pushUnique(urlCandidates, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
collectMagnetOrInfoHash(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const magnetMatch = rawContent.match(/magnet:\?xt=urn:[^"'\s<>]+/i);
|
||||||
|
if (magnetMatch) {
|
||||||
|
collectMagnetOrInfoHash(magnetMatch[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
extractInfoHashesFromString(rawContent, pushInfoHash);
|
||||||
|
traverseForInfoHashes(parsedContent, pushInfoHash);
|
||||||
|
|
||||||
|
const magnet = magnetCandidates.find(Boolean) || "";
|
||||||
|
const infoHash = infoHashCandidates.find(Boolean) || "";
|
||||||
|
const url =
|
||||||
|
urlCandidates.find(
|
||||||
|
(candidate) => candidate && !candidate.toLowerCase().startsWith("magnet:")
|
||||||
|
) || "";
|
||||||
|
|
||||||
|
const rawVersion = parsedContent.version;
|
||||||
|
let version = 0;
|
||||||
|
if (typeof rawVersion === "number" && Number.isFinite(rawVersion)) {
|
||||||
|
version = rawVersion;
|
||||||
|
} else if (typeof rawVersion === "string") {
|
||||||
|
const parsedVersion = Number(rawVersion);
|
||||||
|
if (!Number.isNaN(parsedVersion)) {
|
||||||
|
version = parsedVersion;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
parsedContent,
|
||||||
|
parseError,
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
magnet,
|
||||||
|
infoHash,
|
||||||
|
version,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function magnetFromText(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const match = value.match(LEGACY_MAGNET_PATTERN);
|
||||||
|
return match ? match[0] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLegacyMagnetInEvent(event = {}) {
|
||||||
|
const fromContent = magnetFromText(event?.content);
|
||||||
|
if (fromContent) {
|
||||||
|
return fromContent.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = Array.isArray(event?.tags) ? event.tags : [];
|
||||||
|
for (const tag of tags) {
|
||||||
|
if (!Array.isArray(tag) || tag.length < 2) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = typeof tag[0] === "string" ? tag[0].trim().toLowerCase() : "";
|
||||||
|
const value = typeof tag[1] === "string" ? tag[1].trim() : "";
|
||||||
|
if (!value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === "magnet" && value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
const fromTag = magnetFromText(value);
|
||||||
|
if (fromTag) {
|
||||||
|
return fromTag.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@
|
|||||||
import { initChannelProfileView } from "./channelProfile.js";
|
import { initChannelProfileView } from "./channelProfile.js";
|
||||||
import { subscriptions } from "./subscriptions.js";
|
import { subscriptions } from "./subscriptions.js";
|
||||||
|
|
||||||
|
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load a partial view by URL into the #viewContainer.
|
* Load a partial view by URL into the #viewContainer.
|
||||||
*/
|
*/
|
||||||
@@ -23,6 +25,10 @@ export async function loadView(viewUrl) {
|
|||||||
// Copy and execute any inline scripts
|
// Copy and execute any inline scripts
|
||||||
const scriptTags = doc.querySelectorAll("script");
|
const scriptTags = doc.querySelectorAll("script");
|
||||||
scriptTags.forEach((oldScript) => {
|
scriptTags.forEach((oldScript) => {
|
||||||
|
const src = oldScript.getAttribute("src") || "";
|
||||||
|
if (src && TRACKING_SCRIPT_PATTERN.test(src)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const newScript = document.createElement("script");
|
const newScript = document.createElement("script");
|
||||||
Array.from(oldScript.attributes).forEach((attr) => {
|
Array.from(oldScript.attributes).forEach((attr) => {
|
||||||
newScript.setAttribute(attr.name, attr.value);
|
newScript.setAttribute(attr.name, attr.value);
|
||||||
@@ -44,6 +50,9 @@ export const viewInitRegistry = {
|
|||||||
"most-recent-videos": () => {
|
"most-recent-videos": () => {
|
||||||
if (window.app && window.app.loadVideos) {
|
if (window.app && window.app.loadVideos) {
|
||||||
window.app.videoList = document.getElementById("videoList");
|
window.app.videoList = document.getElementById("videoList");
|
||||||
|
if (window.app.attachVideoListHandler) {
|
||||||
|
window.app.attachVideoListHandler();
|
||||||
|
}
|
||||||
window.app.loadVideos();
|
window.app.loadVideos();
|
||||||
}
|
}
|
||||||
// Force profile updates after the new view is in place.
|
// Force profile updates after the new view is in place.
|
||||||
|
|||||||
+477
-16
@@ -2,11 +2,145 @@
|
|||||||
|
|
||||||
import WebTorrent from "./webtorrent.min.js";
|
import WebTorrent from "./webtorrent.min.js";
|
||||||
|
|
||||||
|
const DEFAULT_PROBE_TRACKERS = Object.freeze([
|
||||||
|
"wss://tracker.btorrent.xyz",
|
||||||
|
"wss://tracker.openwebtorrent.com",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function decodeComponentSafe(value) {
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch (err) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrackerList(trackers) {
|
||||||
|
const normalized = [];
|
||||||
|
const seen = new Set();
|
||||||
|
if (!Array.isArray(trackers)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
trackers.forEach((tracker) => {
|
||||||
|
if (typeof tracker !== "string") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const trimmed = tracker.trim();
|
||||||
|
if (!trimmed || !/^wss:\/\//i.test(trimmed)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const lower = trimmed.toLowerCase();
|
||||||
|
if (seen.has(lower)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seen.add(lower);
|
||||||
|
normalized.push(trimmed);
|
||||||
|
});
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendProbeTrackers(magnetURI, trackers) {
|
||||||
|
if (typeof magnetURI !== "string") {
|
||||||
|
return { magnet: "", appended: false, hasProbeTrackers: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmedMagnet = magnetURI.trim();
|
||||||
|
if (!trimmedMagnet) {
|
||||||
|
return { magnet: "", appended: false, hasProbeTrackers: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeTrackers = normalizeTrackerList(trackers);
|
||||||
|
if (!probeTrackers.length) {
|
||||||
|
return {
|
||||||
|
magnet: trimmedMagnet,
|
||||||
|
appended: false,
|
||||||
|
hasProbeTrackers: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackerSet = new Set();
|
||||||
|
const [withoutFragment, fragment = ""] = trimmedMagnet.split("#", 2);
|
||||||
|
const [, queryPart = ""] = withoutFragment.split("?", 2);
|
||||||
|
|
||||||
|
if (queryPart) {
|
||||||
|
queryPart
|
||||||
|
.split("&")
|
||||||
|
.map((segment) => segment.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.forEach((segment) => {
|
||||||
|
const [rawKey, rawValue = ""] = segment.split("=", 2);
|
||||||
|
if (!rawKey || rawKey.trim().toLowerCase() !== "tr") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const decoded = decodeComponentSafe(rawValue).trim().toLowerCase();
|
||||||
|
if (decoded) {
|
||||||
|
trackerSet.add(decoded);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedProbe = probeTrackers.map((url) => url.toLowerCase());
|
||||||
|
const hadProbeTracker = normalizedProbe.some((url) => trackerSet.has(url));
|
||||||
|
|
||||||
|
const toAppend = [];
|
||||||
|
probeTrackers.forEach((tracker, index) => {
|
||||||
|
const normalizedTracker = normalizedProbe[index];
|
||||||
|
if (trackerSet.has(normalizedTracker)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
trackerSet.add(normalizedTracker);
|
||||||
|
toAppend.push(`tr=${encodeURIComponent(tracker)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!toAppend.length) {
|
||||||
|
return {
|
||||||
|
magnet: trimmedMagnet,
|
||||||
|
appended: false,
|
||||||
|
hasProbeTrackers: hadProbeTracker,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = queryPart ? "&" : "?";
|
||||||
|
const augmented = `${withoutFragment}${separator}${toAppend.join("&")}`;
|
||||||
|
const finalMagnet = fragment
|
||||||
|
? `${augmented}#${fragment}`
|
||||||
|
: augmented;
|
||||||
|
|
||||||
|
return {
|
||||||
|
magnet: finalMagnet,
|
||||||
|
appended: true,
|
||||||
|
hasProbeTrackers: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumber(value, fallback = 0) {
|
||||||
|
const coerced = Number(value);
|
||||||
|
if (Number.isFinite(coerced)) {
|
||||||
|
return coerced;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toError(err) {
|
||||||
|
if (err instanceof Error) {
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new Error(String(err));
|
||||||
|
} catch (stringifyError) {
|
||||||
|
return new Error("Unknown error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class TorrentClient {
|
export class TorrentClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Reusable objects and flags
|
// Reusable objects and flags
|
||||||
this.client = null;
|
this.client = null;
|
||||||
this.currentTorrent = null;
|
this.currentTorrent = null;
|
||||||
|
this.probeClient = null;
|
||||||
|
|
||||||
// Service worker registration is cached
|
// Service worker registration is cached
|
||||||
this.swRegistration = null;
|
this.swRegistration = null;
|
||||||
@@ -16,6 +150,155 @@ export class TorrentClient {
|
|||||||
this.TIMEOUT_DURATION = 60000;
|
this.TIMEOUT_DURATION = 60000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ensureClientForProbe() {
|
||||||
|
if (!this.probeClient) {
|
||||||
|
this.probeClient = new WebTorrent();
|
||||||
|
}
|
||||||
|
return this.probeClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async probePeers(
|
||||||
|
magnetURI,
|
||||||
|
{ timeoutMs = 8000, maxWebConns = 2, polls = 3 } = {}
|
||||||
|
) {
|
||||||
|
const magnet = typeof magnetURI === "string" ? magnetURI.trim() : "";
|
||||||
|
if (!magnet) {
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
peers: 0,
|
||||||
|
reason: "invalid",
|
||||||
|
appendedTrackers: false,
|
||||||
|
hasProbeTrackers: false,
|
||||||
|
usedTrackers: [...TorrentClient.PROBE_TRACKERS],
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackers = normalizeTrackerList(TorrentClient.PROBE_TRACKERS);
|
||||||
|
const { magnet: augmentedMagnet, appended, hasProbeTrackers } =
|
||||||
|
appendProbeTrackers(magnet, trackers);
|
||||||
|
|
||||||
|
if (!hasProbeTrackers) {
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
peers: 0,
|
||||||
|
reason: "no-trackers",
|
||||||
|
appendedTrackers: false,
|
||||||
|
hasProbeTrackers: false,
|
||||||
|
usedTrackers: trackers,
|
||||||
|
durationMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = this.ensureClientForProbe();
|
||||||
|
const safeTimeout = Math.max(0, normalizeNumber(timeoutMs, 8000));
|
||||||
|
const safePolls = Math.max(1, Math.floor(normalizeNumber(polls, 3)));
|
||||||
|
const safeMaxWebConns = Math.max(1, Math.floor(normalizeNumber(maxWebConns, 2)));
|
||||||
|
const pollInterval = Math.max(
|
||||||
|
250,
|
||||||
|
Math.floor(safeTimeout / Math.max(1, safePolls))
|
||||||
|
);
|
||||||
|
|
||||||
|
const startedAt =
|
||||||
|
typeof performance !== "undefined" && performance?.now
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let torrent = null;
|
||||||
|
let timeoutId = null;
|
||||||
|
let pollId = null;
|
||||||
|
|
||||||
|
const finalize = (overrides = {}) => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settled = true;
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
if (pollId) {
|
||||||
|
clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
}
|
||||||
|
if (torrent) {
|
||||||
|
try {
|
||||||
|
torrent.destroy({ destroyStore: true });
|
||||||
|
} catch (err) {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const endedAt =
|
||||||
|
typeof performance !== "undefined" && performance?.now
|
||||||
|
? performance.now()
|
||||||
|
: Date.now();
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
healthy: false,
|
||||||
|
peers: 0,
|
||||||
|
reason: "timeout",
|
||||||
|
appendedTrackers: appended,
|
||||||
|
hasProbeTrackers,
|
||||||
|
usedTrackers: trackers,
|
||||||
|
durationMs: Math.max(0, endedAt - startedAt),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
torrent = client.add(augmentedMagnet, {
|
||||||
|
announce: trackers,
|
||||||
|
maxWebConns: safeMaxWebConns,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
finalize({
|
||||||
|
reason: "error",
|
||||||
|
error: toError(err),
|
||||||
|
peers: 0,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const settleHealthy = () => {
|
||||||
|
const peers = Math.max(1, Math.floor(normalizeNumber(torrent?.numPeers, 1)));
|
||||||
|
finalize({ healthy: true, peers, reason: "peer" });
|
||||||
|
};
|
||||||
|
|
||||||
|
torrent.once("wire", settleHealthy);
|
||||||
|
|
||||||
|
torrent.once("error", (err) => {
|
||||||
|
finalize({
|
||||||
|
healthy: false,
|
||||||
|
reason: "error",
|
||||||
|
error: toError(err),
|
||||||
|
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (safeTimeout > 0) {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
finalize({
|
||||||
|
healthy: false,
|
||||||
|
peers: Math.max(0, Math.floor(normalizeNumber(torrent?.numPeers, 0))),
|
||||||
|
});
|
||||||
|
}, safeTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
pollId = setInterval(() => {
|
||||||
|
if (!torrent || settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const peers = Math.max(0, Math.floor(normalizeNumber(torrent.numPeers, 0)));
|
||||||
|
if (peers > 0) {
|
||||||
|
finalize({ healthy: true, peers, reason: "peer" });
|
||||||
|
}
|
||||||
|
}, pollInterval);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
log(msg) {
|
log(msg) {
|
||||||
console.log(msg);
|
console.log(msg);
|
||||||
}
|
}
|
||||||
@@ -43,6 +326,13 @@ export class TorrentClient {
|
|||||||
// 2) If we haven’t registered the service worker yet, do it now
|
// 2) If we haven’t registered the service worker yet, do it now
|
||||||
if (!this.swRegistration) {
|
if (!this.swRegistration) {
|
||||||
this.swRegistration = await this.setupServiceWorker();
|
this.swRegistration = await this.setupServiceWorker();
|
||||||
|
} else {
|
||||||
|
// Even with an existing registration we still wait for control so that a
|
||||||
|
// transient controller drop (e.g. Chrome devtools unregister/reload) does
|
||||||
|
// not resurrect the grey-screen regression mentioned in
|
||||||
|
// waitForActiveController().
|
||||||
|
this.requestClientsClaim(this.swRegistration);
|
||||||
|
await this.waitForActiveController(this.swRegistration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +371,117 @@ export class TorrentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a live service worker is actively controlling the page before we
|
||||||
|
* start WebTorrent streaming.
|
||||||
|
*
|
||||||
|
* This regression has bitten us repeatedly: the first playback attempt after
|
||||||
|
* a cold load would get stuck on a grey frame because `navigator.serviceWorker`
|
||||||
|
* had finished installing but never claimed the page yet. WebTorrent's
|
||||||
|
* service-worker proxy never attached, so `file.streamTo(videoElement)` fed
|
||||||
|
* data into the void. Refreshing the page “fixed” it because the worker became
|
||||||
|
* the controller during navigation. Future refactors **must not** remove this
|
||||||
|
* guard — wait for `controllerchange` whenever `controller` is still null.
|
||||||
|
*/
|
||||||
|
requestClientsClaim(registration = this.swRegistration) {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const activeWorker = registration?.active;
|
||||||
|
if (!activeWorker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some Chromium builds require an explicit startMessages() call before a
|
||||||
|
// yet-to-claim worker will accept postMessage traffic. Calling it is a
|
||||||
|
// harmless no-op elsewhere.
|
||||||
|
if (navigator.serviceWorker.startMessages) {
|
||||||
|
navigator.serviceWorker.startMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
activeWorker.postMessage({ type: "ENSURE_CLIENTS_CLAIM" });
|
||||||
|
} catch (err) {
|
||||||
|
this.log("Failed to request clients.claim():", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForActiveController(registration = this.swRegistration) {
|
||||||
|
if (!("serviceWorker" in navigator)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
return navigator.serviceWorker.controller;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.requestClientsClaim(registration);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let timeoutId = null;
|
||||||
|
let pollId = null;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
timeoutId = null;
|
||||||
|
}
|
||||||
|
if (pollId) {
|
||||||
|
clearInterval(pollId);
|
||||||
|
pollId = null;
|
||||||
|
}
|
||||||
|
navigator.serviceWorker.removeEventListener(
|
||||||
|
"controllerchange",
|
||||||
|
onControllerChange
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const maybeResolve = () => {
|
||||||
|
const controller = navigator.serviceWorker.controller;
|
||||||
|
if (controller) {
|
||||||
|
cleanup();
|
||||||
|
resolve(controller);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onControllerChange = () => {
|
||||||
|
if (maybeResolve()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// If we received a controllerchange event but still don't have a
|
||||||
|
// controller it usually means the new worker hasn't claimed this page
|
||||||
|
// yet. Ask it again to be safe.
|
||||||
|
this.requestClientsClaim(registration);
|
||||||
|
};
|
||||||
|
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Service worker controller claim timeout"));
|
||||||
|
}, this.TIMEOUT_DURATION);
|
||||||
|
|
||||||
|
pollId = setInterval(() => {
|
||||||
|
if (maybeResolve()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.requestClientsClaim(registration);
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
navigator.serviceWorker.addEventListener(
|
||||||
|
"controllerchange",
|
||||||
|
onControllerChange
|
||||||
|
);
|
||||||
|
|
||||||
|
// One last check in case the controller appeared between the earlier
|
||||||
|
// synchronous guard and the promise wiring above.
|
||||||
|
if (!maybeResolve()) {
|
||||||
|
this.requestClientsClaim(registration);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async setupServiceWorker() {
|
async setupServiceWorker() {
|
||||||
try {
|
try {
|
||||||
const isBraveBrowser = await this.isBrave();
|
const isBraveBrowser = await this.isBrave();
|
||||||
@@ -92,7 +493,15 @@ export class TorrentClient {
|
|||||||
throw new Error("Service Worker not supported or disabled");
|
throw new Error("Service Worker not supported or disabled");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Brave-specific logic
|
// Brave-specific logic: Brave Shields has a long-standing bug where
|
||||||
|
// stale service worker registrations linger even after we ship fixes.
|
||||||
|
// When that happens, the outdated worker keeps intercepting requests
|
||||||
|
// and WebTorrent fails to spin up, leaving playback broken until the
|
||||||
|
// user manually nukes the registration. To guarantee a clean slate we
|
||||||
|
// blanket-unregister every worker, then pause briefly so Brave can
|
||||||
|
// finish tearing down the old instance before we register the fresh
|
||||||
|
// one again. That delay is intentional—future tweaks should not shorten
|
||||||
|
// or remove it without understanding the regression risk.
|
||||||
if (isBraveBrowser) {
|
if (isBraveBrowser) {
|
||||||
this.log("Checking Brave configuration...");
|
this.log("Checking Brave configuration...");
|
||||||
if (!navigator.serviceWorker) {
|
if (!navigator.serviceWorker) {
|
||||||
@@ -160,6 +569,16 @@ export class TorrentClient {
|
|||||||
throw new Error("Service worker not active after ready state");
|
throw new Error("Service worker not active after ready state");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Give the newly activated worker an explicit nudge to claim the page
|
||||||
|
// before we continue. This keeps Chromium's occasionally sluggish claim
|
||||||
|
// hand-offs from derailing the subsequent wait below.
|
||||||
|
this.requestClientsClaim(registration);
|
||||||
|
|
||||||
|
// See waitForActiveController() docstring for why this must remain in
|
||||||
|
// place. We intentionally wait here instead of racing with playback so a
|
||||||
|
// newly installed worker claims the page before WebTorrent spins up.
|
||||||
|
await this.waitForActiveController(registration);
|
||||||
|
|
||||||
// Force the SW to check for updates
|
// Force the SW to check for updates
|
||||||
registration.update();
|
registration.update();
|
||||||
this.log("Service worker ready");
|
this.log("Service worker ready");
|
||||||
@@ -171,8 +590,30 @@ export class TorrentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
attemptAutoplay(videoElement, context = "webtorrent") {
|
||||||
|
if (!videoElement || typeof videoElement.play !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
videoElement
|
||||||
|
.play()
|
||||||
|
.catch((err) => {
|
||||||
|
this.log(`Autoplay failed (${context} path):`, err);
|
||||||
|
if (videoElement.muted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.log(`Retrying with muted autoplay (${context} path).`);
|
||||||
|
videoElement.muted = true;
|
||||||
|
videoElement.play().catch((err2) => {
|
||||||
|
this.log(`Muted autoplay also failed (${context} path):`, err2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Chrome-based browsers
|
// Handle Chrome-based browsers
|
||||||
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
||||||
|
// Prune demo web seeds/trackers that chronically trip Chromium CORS and
|
||||||
|
// deliberately mutate `torrent._opts` as a sanctioned WebTorrent workaround.
|
||||||
torrent.on("warning", (err) => {
|
torrent.on("warning", (err) => {
|
||||||
if (err && typeof err.message === "string") {
|
if (err && typeof err.message === "string") {
|
||||||
if (
|
if (
|
||||||
@@ -203,18 +644,20 @@ export class TorrentClient {
|
|||||||
return reject(new Error("No compatible video file found in torrent"));
|
return reject(new Error("No compatible video file found in torrent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
videoElement.muted = true;
|
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
|
||||||
videoElement.crossOrigin = "anonymous";
|
videoElement.crossOrigin = "anonymous";
|
||||||
|
|
||||||
videoElement.addEventListener("error", (e) => {
|
videoElement.addEventListener("error", (e) => {
|
||||||
this.log("Video error:", e.target.error);
|
this.log("Video error:", e.target.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
videoElement.addEventListener("canplay", () => {
|
videoElement.addEventListener(
|
||||||
videoElement.play().catch((err) => {
|
"canplay",
|
||||||
this.log("Autoplay failed:", err);
|
() => {
|
||||||
});
|
this.attemptAutoplay(videoElement, "chrome");
|
||||||
});
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file.streamTo(videoElement);
|
file.streamTo(videoElement);
|
||||||
@@ -240,18 +683,20 @@ export class TorrentClient {
|
|||||||
return reject(new Error("No compatible video file found in torrent"));
|
return reject(new Error("No compatible video file found in torrent"));
|
||||||
}
|
}
|
||||||
|
|
||||||
videoElement.muted = true;
|
// Satisfy autoplay requirements and keep cross-origin chunks usable (e.g., for snapshots).
|
||||||
videoElement.crossOrigin = "anonymous";
|
videoElement.crossOrigin = "anonymous";
|
||||||
|
|
||||||
videoElement.addEventListener("error", (e) => {
|
videoElement.addEventListener("error", (e) => {
|
||||||
this.log("Video error (Firefox path):", e.target.error);
|
this.log("Video error (Firefox path):", e.target.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
videoElement.addEventListener("canplay", () => {
|
videoElement.addEventListener(
|
||||||
videoElement.play().catch((err) => {
|
"canplay",
|
||||||
this.log("Autoplay failed:", err);
|
() => {
|
||||||
});
|
this.attemptAutoplay(videoElement, "firefox");
|
||||||
});
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
|
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
|
||||||
@@ -272,7 +717,7 @@ export class TorrentClient {
|
|||||||
* Initiates streaming of a torrent magnet to a <video> element.
|
* Initiates streaming of a torrent magnet to a <video> element.
|
||||||
* Ensures the service worker is set up only once and the client is reused.
|
* Ensures the service worker is set up only once and the client is reused.
|
||||||
*/
|
*/
|
||||||
async streamVideo(magnetURI, videoElement) {
|
async streamVideo(magnetURI, videoElement, opts = {}) {
|
||||||
try {
|
try {
|
||||||
// 1) Make sure we have a WebTorrent client and a valid SW registration.
|
// 1) Make sure we have a WebTorrent client and a valid SW registration.
|
||||||
await this.init();
|
await this.init();
|
||||||
@@ -288,6 +733,16 @@ export class TorrentClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const isFirefoxBrowser = this.isFirefox();
|
const isFirefoxBrowser = this.isFirefox();
|
||||||
|
const candidateUrls = Array.isArray(opts?.urlList)
|
||||||
|
? opts.urlList
|
||||||
|
.map((entry) => (typeof entry === "string" ? entry.trim() : ""))
|
||||||
|
.filter((entry) => /^https?:\/\//i.test(entry))
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const chromeOptions = { strategy: "sequential" };
|
||||||
|
if (candidateUrls.length) {
|
||||||
|
chromeOptions.urlList = candidateUrls;
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
// 3) Add the torrent to the client and handle accordingly.
|
// 3) Add the torrent to the client and handle accordingly.
|
||||||
@@ -295,7 +750,7 @@ export class TorrentClient {
|
|||||||
this.log("Starting torrent download (Firefox path)");
|
this.log("Starting torrent download (Firefox path)");
|
||||||
this.client.add(
|
this.client.add(
|
||||||
magnetURI,
|
magnetURI,
|
||||||
{ strategy: "sequential", maxWebConns: 4 },
|
{ ...chromeOptions, maxWebConns: 4 },
|
||||||
(torrent) => {
|
(torrent) => {
|
||||||
this.log("Torrent added (Firefox path):", torrent.name);
|
this.log("Torrent added (Firefox path):", torrent.name);
|
||||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||||
@@ -303,7 +758,7 @@ export class TorrentClient {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
this.log("Starting torrent download (Chrome path)");
|
this.log("Starting torrent download (Chrome path)");
|
||||||
this.client.add(magnetURI, { strategy: "sequential" }, (torrent) => {
|
this.client.add(magnetURI, chromeOptions, (torrent) => {
|
||||||
this.log("Torrent added (Chrome path):", torrent.name);
|
this.log("Torrent added (Chrome path):", torrent.name);
|
||||||
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||||
});
|
});
|
||||||
@@ -330,6 +785,10 @@ export class TorrentClient {
|
|||||||
await this.client.destroy();
|
await this.client.destroy();
|
||||||
this.client = null;
|
this.client = null;
|
||||||
}
|
}
|
||||||
|
if (this.probeClient) {
|
||||||
|
await this.probeClient.destroy();
|
||||||
|
this.probeClient = null;
|
||||||
|
}
|
||||||
this.currentTorrent = null;
|
this.currentTorrent = null;
|
||||||
this.serverCreated = false;
|
this.serverCreated = false;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -338,4 +797,6 @@ export class TorrentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TorrentClient.PROBE_TRACKERS = DEFAULT_PROBE_TRACKERS;
|
||||||
|
|
||||||
export const torrentClient = new TorrentClient();
|
export const torrentClient = new TorrentClient();
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
|
Promise.all(cacheNames.map((cacheName) => caches.delete(cacheName)))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (event.data && event.data.type === "ENSURE_CLIENTS_CLAIM") {
|
||||||
|
event.waitUntil(clients.claim());
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Immediately install and skip waiting
|
// Immediately install and skip waiting
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import "./test-helpers/setup-localstorage.mjs";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseVideoEventPayload } from "../js/videoEventUtils.js";
|
||||||
|
import { convertEventToVideo } from "../js/nostr.js";
|
||||||
|
import { deriveTorrentPlaybackConfig } from "../js/playbackUtils.js";
|
||||||
|
|
||||||
|
const LEGACY_INFO_HASH = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
|
||||||
|
(function testParseDetectsBareInfoHashInJson() {
|
||||||
|
const event = {
|
||||||
|
id: "evt-json",
|
||||||
|
content: JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
title: "Legacy note",
|
||||||
|
magnet: LEGACY_INFO_HASH,
|
||||||
|
}),
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseVideoEventPayload(event);
|
||||||
|
assert.equal(parsed.magnet, "", "Bare info hash should not be treated as a magnet URI");
|
||||||
|
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testParseDetectsInfoHashInRawContentAndTags() {
|
||||||
|
const event = {
|
||||||
|
id: "evt-tag",
|
||||||
|
content: JSON.stringify({ title: "tag-sourced" }),
|
||||||
|
tags: [["magnet", LEGACY_INFO_HASH]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseVideoEventPayload(event);
|
||||||
|
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testParseDetectsInfoHashInRawString() {
|
||||||
|
const event = {
|
||||||
|
id: "evt-raw",
|
||||||
|
content: `legacy magnet ${LEGACY_INFO_HASH} broken json`,
|
||||||
|
tags: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = parseVideoEventPayload(event);
|
||||||
|
assert.equal(parsed.infoHash, LEGACY_INFO_HASH);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testConvertFlagsBareInfoHashAsInvalid() {
|
||||||
|
const event = {
|
||||||
|
id: "evt-convert",
|
||||||
|
pubkey: "pk",
|
||||||
|
created_at: 1,
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
title: "Legacy conversion",
|
||||||
|
magnet: LEGACY_INFO_HASH,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = convertEventToVideo(event);
|
||||||
|
assert.equal(video.invalid, true, "Bare info-hash payloads should be flagged");
|
||||||
|
assert.equal(video.reason, "missing playable source");
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testLegacyEventWithoutTitleStillLoads() {
|
||||||
|
const event = {
|
||||||
|
id: "evt-no-title",
|
||||||
|
pubkey: "pk2",
|
||||||
|
created_at: 2,
|
||||||
|
tags: [],
|
||||||
|
content: JSON.stringify({
|
||||||
|
version: 1,
|
||||||
|
magnet: `magnet:?xt=urn:btih:${LEGACY_INFO_HASH}`,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = convertEventToVideo(event);
|
||||||
|
assert.equal(video.invalid, false, "Legacy events without title should fallback");
|
||||||
|
assert.ok(video.title && video.title.length > 0, "Fallback title should be provided");
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testConvertRecoversMagnetFromMagnetTag() {
|
||||||
|
const magnetUri =
|
||||||
|
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=legacy-tag&tr=wss://tracker.openwebtorrent.com`;
|
||||||
|
const event = {
|
||||||
|
id: "evt-magnet-tag",
|
||||||
|
pubkey: "pk3",
|
||||||
|
created_at: 3,
|
||||||
|
// Deliberately invalid JSON so the converter must rely on scavenging.
|
||||||
|
content: "{not valid json}",
|
||||||
|
tags: [["magnet", magnetUri]],
|
||||||
|
};
|
||||||
|
|
||||||
|
const video = convertEventToVideo(event);
|
||||||
|
assert.equal(video.invalid, false, "Magnet in tag should still be playable");
|
||||||
|
assert.equal(video.magnet, magnetUri);
|
||||||
|
assert.equal(video.rawMagnet, magnetUri);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testPlaybackConfigNormalizesInfoHash() {
|
||||||
|
const result = deriveTorrentPlaybackConfig({
|
||||||
|
magnet: "",
|
||||||
|
infoHash: LEGACY_INFO_HASH,
|
||||||
|
url: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(result.magnet.startsWith("magnet:?"));
|
||||||
|
const xtValues = new URL(result.magnet).searchParams.getAll("xt");
|
||||||
|
assert.deepEqual(xtValues, [`urn:btih:${LEGACY_INFO_HASH}`]);
|
||||||
|
assert.equal(result.usedInfoHash, true);
|
||||||
|
assert.equal(result.fallbackMagnet, "");
|
||||||
|
assert.equal(result.provided, true);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testPlaybackConfigDecodesEncodedMagnet() {
|
||||||
|
const rawMagnet =
|
||||||
|
`magnet:?xt=urn:btih:${LEGACY_INFO_HASH}&dn=Legacy+Example`;
|
||||||
|
const encodedMagnet = encodeURIComponent(rawMagnet);
|
||||||
|
const result = deriveTorrentPlaybackConfig({
|
||||||
|
magnet: encodedMagnet,
|
||||||
|
infoHash: "",
|
||||||
|
url: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.ok(result.magnet.startsWith("magnet:?"));
|
||||||
|
const parsed = new URL(result.magnet);
|
||||||
|
assert.equal(
|
||||||
|
parsed.searchParams.get("xt"),
|
||||||
|
`urn:btih:${LEGACY_INFO_HASH}`,
|
||||||
|
"Expected encoded magnet to be normalized"
|
||||||
|
);
|
||||||
|
assert.equal(result.provided, true);
|
||||||
|
assert.equal(result.usedInfoHash, false);
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log("legacy infohash tests passed");
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { WSS_TRACKERS } from "../js/constants.js";
|
||||||
|
import { normalizeAndAugmentMagnet, safeDecodeMagnet } from "../js/magnetUtils.js";
|
||||||
|
import { normalizeAndAugmentMagnet as formNormalize } from "../js/magnet.js";
|
||||||
|
|
||||||
|
function getParamValues(magnet, key) {
|
||||||
|
const parsed = new URL(magnet);
|
||||||
|
return parsed.searchParams.getAll(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
(function testBareInfoHashNormalization() {
|
||||||
|
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
const result = normalizeAndAugmentMagnet(infoHash);
|
||||||
|
assert.ok(result.didChange, "Bare info hash should mark didChange true");
|
||||||
|
const xtValues = getParamValues(result.magnet, "xt");
|
||||||
|
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
|
||||||
|
const trackerValues = getParamValues(result.magnet, "tr");
|
||||||
|
for (const tracker of WSS_TRACKERS) {
|
||||||
|
assert.ok(
|
||||||
|
trackerValues.includes(tracker),
|
||||||
|
`Expected tracker ${tracker} to be appended`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testEncodedXtDecoding() {
|
||||||
|
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||||
|
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}&dn=Example`;
|
||||||
|
const result = normalizeAndAugmentMagnet(encodedMagnet);
|
||||||
|
const xtValues = getParamValues(result.magnet, "xt");
|
||||||
|
assert.deepEqual(xtValues, [`urn:btih:${infoHash}`]);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testDuplicateTrackerFiltering() {
|
||||||
|
const infoHash = "fedcba9876543210fedcba9876543210fedcba98";
|
||||||
|
const baseMagnet =
|
||||||
|
`magnet:?xt=urn:btih:${infoHash}` +
|
||||||
|
"&tr=wss://tracker.openwebtorrent.com" +
|
||||||
|
"&tr=WsS://tracker.openwebtorrent.com/" +
|
||||||
|
"&tr=https://not-allowed.example";
|
||||||
|
const result = normalizeAndAugmentMagnet(baseMagnet, {
|
||||||
|
extraTrackers: [
|
||||||
|
"wss://tracker.fastcast.nz",
|
||||||
|
"http://tracker.invalid",
|
||||||
|
"wss://tracker.openwebtorrent.com",
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const trackers = getParamValues(result.magnet, "tr");
|
||||||
|
const openwebCount = trackers.filter(
|
||||||
|
(value) => value === "wss://tracker.openwebtorrent.com"
|
||||||
|
).length;
|
||||||
|
assert.equal(openwebCount, 1, "Duplicate WSS tracker should be collapsed");
|
||||||
|
assert.ok(
|
||||||
|
trackers.includes("wss://tracker.fastcast.nz"),
|
||||||
|
"Expected extra WSS tracker to be preserved"
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
!trackers.includes("http://tracker.invalid"),
|
||||||
|
"Non-WSS tracker candidates should be ignored"
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testWebSeedHandlingWithLogging() {
|
||||||
|
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
const messages = [];
|
||||||
|
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||||
|
webSeed: [
|
||||||
|
"https://cdn.example.com/video.mp4",
|
||||||
|
"http://cdn.example.com/legacy.mp4",
|
||||||
|
"https://cdn.example.com/video.mp4",
|
||||||
|
],
|
||||||
|
logger: (msg) => messages.push(msg),
|
||||||
|
appProtocol: "https:",
|
||||||
|
});
|
||||||
|
const seeds = getParamValues(result.magnet, "ws");
|
||||||
|
assert.deepEqual(seeds, ["https://cdn.example.com/video.mp4"]);
|
||||||
|
assert.equal(messages.length, 1, "HTTP seed should have been skipped with a log");
|
||||||
|
assert.ok(
|
||||||
|
messages[0].includes("Skipping insecure web seed"),
|
||||||
|
"Expected skip message for HTTP seed"
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testHttpWebSeedAllowedOnHttpOrigin() {
|
||||||
|
const infoHash = "abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||||
|
const result = normalizeAndAugmentMagnet(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||||
|
webSeed: "http://cdn.example.com/video.mp4",
|
||||||
|
appProtocol: "http:",
|
||||||
|
});
|
||||||
|
const seeds = getParamValues(result.magnet, "ws");
|
||||||
|
assert.deepEqual(seeds, ["http://cdn.example.com/video.mp4"]);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testSafeDecodeMagnetHandlesEncodedValues() {
|
||||||
|
const rawMagnet =
|
||||||
|
"magnet:?xt=urn:btih:0123456789abcdef0123456789abcdef01234567&dn=Example";
|
||||||
|
const encodedMagnet = encodeURIComponent(rawMagnet);
|
||||||
|
assert.equal(
|
||||||
|
safeDecodeMagnet(encodedMagnet),
|
||||||
|
rawMagnet,
|
||||||
|
"Expected percent-encoded magnet to be decoded"
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testSafeDecodeMagnetLeavesPlainStringsUntouched() {
|
||||||
|
const rawMagnet =
|
||||||
|
"magnet:?xt=urn:btih:abcdefabcdefabcdefabcdefabcdefabcdefabcd";
|
||||||
|
assert.equal(
|
||||||
|
safeDecodeMagnet(rawMagnet),
|
||||||
|
rawMagnet,
|
||||||
|
"Plain magnet strings should pass through unchanged"
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testFormHelperAugmentsWsXsAndDecodesXt() {
|
||||||
|
const infoHash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||||
|
const encodedMagnet = `magnet:?xt=urn%3Abtih%3A${infoHash}`;
|
||||||
|
const normalized = formNormalize(encodedMagnet, {
|
||||||
|
ws: "https://cdn.example.com/video-base/",
|
||||||
|
xs: "https://cdn.example.com/video.torrent",
|
||||||
|
});
|
||||||
|
const parsed = new URL(normalized);
|
||||||
|
assert.equal(
|
||||||
|
parsed.searchParams.get("xt"),
|
||||||
|
`urn:btih:${infoHash}`,
|
||||||
|
"Form helper should decode encoded xt payloads"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parsed.searchParams.getAll("ws"),
|
||||||
|
["https://cdn.example.com/video-base/"],
|
||||||
|
"Expected ws hint to be appended"
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
parsed.searchParams.getAll("xs"),
|
||||||
|
["https://cdn.example.com/video.torrent"],
|
||||||
|
"Expected xs hint to be appended"
|
||||||
|
);
|
||||||
|
const trackers = parsed.searchParams.getAll("tr");
|
||||||
|
for (const tracker of WSS_TRACKERS) {
|
||||||
|
assert.ok(
|
||||||
|
trackers.includes(tracker),
|
||||||
|
`Expected helper to append tracker ${tracker}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function testFormHelperSkipsInsecureWsOnHttpsOrigin() {
|
||||||
|
const infoHash = "0123456789abcdef0123456789abcdef01234567";
|
||||||
|
const normalized = formNormalize(`magnet:?xt=urn:btih:${infoHash}`, {
|
||||||
|
ws: "http://cdn.example.com/video-base/",
|
||||||
|
});
|
||||||
|
const parsed = new URL(normalized);
|
||||||
|
assert.equal(
|
||||||
|
parsed.searchParams.getAll("ws").length,
|
||||||
|
0,
|
||||||
|
"HTTP ws value should be skipped on HTTPS origins"
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
console.log("magnet-utils tests passed");
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<title>Nostr Ephemeral DM Tester (Using finalizeEvent)</title>
|
<title>Nostr Ephemeral DM Tester (Using finalizeEvent)</title>
|
||||||
|
<script src="../js/tracking.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
/* Basic styling for the demo page */
|
/* Basic styling for the demo page */
|
||||||
body {
|
body {
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
if (typeof globalThis.localStorage === "undefined") {
|
||||||
|
const store = new Map();
|
||||||
|
globalThis.localStorage = {
|
||||||
|
getItem(key) {
|
||||||
|
return store.has(key) ? store.get(key) : null;
|
||||||
|
},
|
||||||
|
setItem(key, value) {
|
||||||
|
store.set(String(key), String(value));
|
||||||
|
},
|
||||||
|
removeItem(key) {
|
||||||
|
store.delete(key);
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
store.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
|
|
||||||
<!-- Scripts: webtorrent, angular, etc. -->
|
<!-- Scripts: webtorrent, angular, etc. -->
|
||||||
|
<script src="../js/tracking.js" defer></script>
|
||||||
<script src="bundle.min.js"></script>
|
<script src="bundle.min.js"></script>
|
||||||
<!--<script src="https://cdn.jsdelivr.net/combine/npm/webtorrent/webtorrent.min.js,npm/moment@2,npm/[email protected]/angular.min.js,npm/angular-[email protected]/angular-route.min.js,npm/angular-[email protected]/angular-sanitize.min.js,npm/angular-ui-grid@3/ui-grid.min.js,gh/matowens/ng-notify/dist/ng-notify.min.js,npm/ng-file-[email protected]/dist/ng-file-upload.min.js"></script>-->
|
<!--<script src="https://cdn.jsdelivr.net/combine/npm/webtorrent/webtorrent.min.js,npm/moment@2,npm/[email protected]/angular.min.js,npm/angular-[email protected]/angular-route.min.js,npm/angular-[email protected]/angular-sanitize.min.js,npm/angular-ui-grid@3/ui-grid.min.js,gh/matowens/ng-notify/dist/ng-notify.min.js,npm/ng-file-[email protected]/dist/ng-file-upload.min.js"></script>-->
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,21 @@
|
|||||||
|
|
||||||
<!-- Area for Subscribe/Unsubscribe button -->
|
<!-- Area for Subscribe/Unsubscribe button -->
|
||||||
<div class="px-4 my-4">
|
<div class="px-4 my-4">
|
||||||
<div id="subscribeBtnArea" class="hidden"></div>
|
<div class="flex items-center gap-3">
|
||||||
|
<button
|
||||||
|
id="zapButton"
|
||||||
|
type="button"
|
||||||
|
class="icon-button"
|
||||||
|
aria-label="Send a zap"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src="assets/svg/lightning-bolt.svg"
|
||||||
|
alt="Zap"
|
||||||
|
class="icon-image"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div id="subscribeBtnArea" class="hidden"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr class="my-6" />
|
<hr class="my-6" />
|
||||||
|
|||||||
Reference in new issue
Block a user