mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +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)`).
|
||||
2. **Login with Nostr**:
|
||||
- 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.
|
||||
- Toggle "Private" for encrypted listings.
|
||||
4. **Stream Videos**:
|
||||
- Play videos directly in the browser using WebTorrent technology.
|
||||
|
||||
### Post a video
|
||||
|
||||
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.
|
||||
|
||||
- **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`**:
|
||||
- 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
|
||||
|
||||
@@ -101,6 +119,21 @@ To run **bitvid** locally:
|
||||
- Follow the [MIT License](https://opensource.org/licenses/MIT).
|
||||
- Use clear, concise commit messages.
|
||||
- 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 name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title></title>
|
||||
<script src="./js/tracking.js" defer></script>
|
||||
<script type="module" crossorigin>
|
||||
var pv = Object.defineProperty;
|
||||
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
|
||||
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">
|
||||
<!-- Top Navigation Links -->
|
||||
@@ -45,178 +50,182 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Dropdown Button for Footer Links -->
|
||||
<!-- Added p-4 around the button for spacing, and styling consistent with top links -->
|
||||
<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" />
|
||||
<!-- Footer Links Toggle -->
|
||||
<div class="md:mt-auto">
|
||||
<div class="p-4">
|
||||
<nav class="space-y-2 text-sm opacity-70">
|
||||
<a
|
||||
href="#view=about"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
<button
|
||||
id="footerDropdownButton"
|
||||
type="button"
|
||||
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
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
<span class="sidebar-text">About</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=community-guidelines"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Guidelines</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=getting-started"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Getting Started</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=roadmap"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<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>
|
||||
|
||||
</svg>
|
||||
<span id="footerDropdownText">More</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer Links Container -->
|
||||
<div id="footerLinksContainer" class="hidden">
|
||||
<hr class="border-gray-700" />
|
||||
<div class="p-4">
|
||||
<nav class="space-y-2 text-sm opacity-70">
|
||||
<a
|
||||
href="#view=about"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/about-icon.svg"
|
||||
alt="About"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">About</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=community-guidelines"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/guidelines-icon.svg"
|
||||
alt="Guidelines"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Guidelines</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=getting-started"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/getting-started-icon.svg"
|
||||
alt="Getting Started"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Getting Started</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=roadmap"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/roadmap-icon.svg"
|
||||
alt="Roadmap"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Roadmap</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=blog"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/blog-icon.svg"
|
||||
alt="Blog"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Blog</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://primal.net/p/npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/nostr-icon.svg"
|
||||
alt="Nostr"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Nostr</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com/PR0M3TH3AN/bitvid"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/github-icon.svg"
|
||||
alt="GitHub"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">GitHub</span>
|
||||
</a>
|
||||
<a
|
||||
href="torrent/beacon.html"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beacon-icon.svg"
|
||||
alt="βeacon"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">βeacon</span>
|
||||
</a>
|
||||
<a
|
||||
href="https://beta.bitvid.network/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/beta-icon.svg"
|
||||
alt="Beta"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Beta</span>
|
||||
</a>
|
||||
<a
|
||||
href="#view=links"
|
||||
class="flex items-center hover:bg-gray-800 px-4 py-2 rounded"
|
||||
>
|
||||
<img
|
||||
src="assets/svg/links-icon.svg"
|
||||
alt="Links"
|
||||
class="w-5 h-5 mr-3"
|
||||
/>
|
||||
<span class="sidebar-text">Links</span>
|
||||
</a>
|
||||
<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>
|
||||
@@ -228,18 +237,15 @@
|
||||
if (btn && footerLinks) {
|
||||
btn.addEventListener("click", function () {
|
||||
footerLinks.classList.toggle("hidden");
|
||||
var isHidden = footerLinks.classList.contains("hidden");
|
||||
btn.setAttribute("aria-expanded", String(!isHidden));
|
||||
var icon = document.getElementById("footerDropdownIcon");
|
||||
var textSpan = document.getElementById("footerDropdownText");
|
||||
if (footerLinks.classList.contains("hidden")) {
|
||||
textSpan.textContent = "More";
|
||||
if (icon) {
|
||||
icon.classList.remove("rotate-180");
|
||||
}
|
||||
} else {
|
||||
textSpan.textContent = "Less";
|
||||
if (icon) {
|
||||
icon.classList.add("rotate-180");
|
||||
}
|
||||
if (textSpan) {
|
||||
textSpan.textContent = isHidden ? "More" : "Less";
|
||||
}
|
||||
if (icon) {
|
||||
icon.classList.toggle("rotate-180", !isHidden);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
>
|
||||
<!-- Top Bar (similar to video-modal) -->
|
||||
<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>
|
||||
<button
|
||||
@@ -52,6 +52,9 @@
|
||||
<!-- Form Content -->
|
||||
<div class="p-6 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>
|
||||
<label
|
||||
for="uploadTitle"
|
||||
@@ -66,18 +69,57 @@
|
||||
/>
|
||||
</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>
|
||||
<label
|
||||
for="uploadMagnet"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Magnet Link</label
|
||||
>Magnet link (optional if URL provided)</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="uploadMagnet"
|
||||
required
|
||||
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="magnet:?xt=urn:btih:..."
|
||||
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>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- Navigation bar - sliding at top -->
|
||||
<div
|
||||
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">
|
||||
<button
|
||||
@@ -51,6 +51,19 @@
|
||||
class="icon-image"
|
||||
/>
|
||||
</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) -->
|
||||
<button id="shareBtn" class="icon-button">
|
||||
<img
|
||||
|
||||
+3
-3
@@ -46,9 +46,9 @@
|
||||
- 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)
|
||||
- Implement visibility filtering for videos:
|
||||
- Show only videos whose magnet links have at least **one active peer online**.
|
||||
- Integrate the filtering mechanism into the video list rendering process.
|
||||
- Update the video list dynamically based on real-time peer availability. (this may not be possible)
|
||||
- Run lightweight URL-health probes so cards stay visible when the hosted playback link responds.
|
||||
- When a URL is unreachable, fall back to WebTorrent and surface items only if their magnets can stream via peers or web seeds.
|
||||
- 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)
|
||||
- Add multi-language support for content and filtration. (v4?)
|
||||
- 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-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||
--modal-motion-duration: 160ms;
|
||||
}
|
||||
|
||||
/* Core Styles */
|
||||
@@ -73,6 +74,92 @@ header img {
|
||||
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 {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
@@ -137,7 +224,7 @@ header img {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #0f172a;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
animation: fadeIn var(--modal-motion-duration) ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
@@ -151,6 +238,12 @@ header img {
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.modal-content {
|
||||
animation-duration: 1ms;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Container */
|
||||
.video-container {
|
||||
width: 100%;
|
||||
@@ -541,3 +634,60 @@ footer a:hover {
|
||||
#sidebar hr {
|
||||
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" />
|
||||
|
||||
<!-- Styles -->
|
||||
<script src="js/tracking.js" defer></script>
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="css/style.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.
|
||||
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 class="mb-8 flex items-center w-full">
|
||||
<!-- Mobile hamburger button (hidden on md+) -->
|
||||
<!-- 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
|
||||
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
|
||||
src="assets/svg/mobile-sidebar-menu-icon.svg"
|
||||
@@ -74,14 +86,18 @@
|
||||
/>
|
||||
</button>
|
||||
<!-- Logo -->
|
||||
<!-- Logo fades in once on initial load. -->
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
class="h-16 fade-in fade-in-delay-100"
|
||||
/>
|
||||
|
||||
<!-- 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 -->
|
||||
<button
|
||||
id="loginButton"
|
||||
@@ -140,7 +156,10 @@
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- 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">
|
||||
seed. zap. subscribe.
|
||||
</h2>
|
||||
|
||||
+92
-18
@@ -7,44 +7,118 @@ class AccessControl {
|
||||
constructor() {
|
||||
// Debug logging for initialization
|
||||
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
|
||||
this.whitelist = new Set(initialWhitelist);
|
||||
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
|
||||
const { data: storedWhitelist, status: whitelistStatus } = this.loadWhitelist();
|
||||
const { data: storedBlacklist, status: blacklistStatus } = this.loadBlacklist();
|
||||
|
||||
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
|
||||
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
|
||||
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
|
||||
|
||||
// Save to localStorage
|
||||
this.saveWhitelist();
|
||||
this.saveBlacklist();
|
||||
console.log("DEBUG: Whitelist after initialization:", [...this.whitelist]);
|
||||
console.log("DEBUG: Blacklist after initialization:", [...this.blacklist]);
|
||||
}
|
||||
|
||||
// Rest of the class remains the same...
|
||||
loadWhitelist() {
|
||||
try {
|
||||
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) {
|
||||
console.error("Error loading whitelist:", error);
|
||||
return [];
|
||||
console.error("Error loading whitelist, using defaults:", error);
|
||||
return { data: null, status: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
loadBlacklist() {
|
||||
try {
|
||||
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) {
|
||||
console.error("Error loading blacklist:", error);
|
||||
return [];
|
||||
console.error("Error loading blacklist, using defaults:", error);
|
||||
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() {
|
||||
try {
|
||||
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
|
||||
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
nostrClient,
|
||||
convertEventToVideo as sharedConvertEventToVideo,
|
||||
} from "./nostr.js";
|
||||
import { app } from "./app.js";
|
||||
import { subscriptions } from "./subscriptions.js"; // <-- NEW import
|
||||
import { attachHealthBadges } from "./gridHealth.js";
|
||||
import { initialBlacklist, initialWhitelist } from "./lists.js";
|
||||
import { isWhitelistEnabled } from "./config.js";
|
||||
|
||||
@@ -44,6 +48,8 @@ export async function initChannelProfileView() {
|
||||
if (btn) btn.classList.add("hidden");
|
||||
}
|
||||
|
||||
setupZapButton();
|
||||
|
||||
// 4) Load user’s profile (banner, avatar, etc.)
|
||||
await loadUserProfile(hexPub);
|
||||
|
||||
@@ -51,6 +57,22 @@ export async function initChannelProfileView() {
|
||||
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,
|
||||
* 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
|
||||
let videos = [];
|
||||
for (const evt of events) {
|
||||
const vid = localConvertEventToVideo(evt);
|
||||
const vid = sharedConvertEventToVideo(evt);
|
||||
if (!vid.invalid && !vid.deleted) {
|
||||
videos.push(vid);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Deduplicate older overshadowed versions => newest only
|
||||
videos = dedupeToNewestByRoot(videos);
|
||||
videos = app?.dedupeVideosByRoot?.(videos) ?? dedupeToNewestByRoot(videos);
|
||||
|
||||
// 5) Filter out blacklisted IDs / authors
|
||||
videos = videos.filter((video) => {
|
||||
@@ -251,6 +273,10 @@ async function loadUserVideos(pubkey) {
|
||||
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
|
||||
const canEdit = video.pubkey === app.pubkey;
|
||||
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
|
||||
hover:bg-red-700 hover:text-white"
|
||||
data-revert-index="${index}"
|
||||
data-revert-event-id="${video.id}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
@@ -298,6 +325,7 @@ async function loadUserVideos(pubkey) {
|
||||
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-event-id="${video.id}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -306,6 +334,7 @@ async function loadUserVideos(pubkey) {
|
||||
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-event-id="${video.id}"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
@@ -322,6 +351,7 @@ async function loadUserVideos(pubkey) {
|
||||
|
||||
const cardEl = document.createElement("div");
|
||||
cardEl.classList.add(
|
||||
"video-card",
|
||||
"bg-gray-900",
|
||||
"rounded-lg",
|
||||
"overflow-hidden",
|
||||
@@ -331,8 +361,42 @@ async function loadUserVideos(pubkey) {
|
||||
"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 = `
|
||||
<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">
|
||||
<img
|
||||
src="${fallbackThumb}"
|
||||
@@ -341,32 +405,86 @@ async function loadUserVideos(pubkey) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||
data-play-magnet="${encodeURIComponent(video.magnet)}"
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
<div class="p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3
|
||||
class="text-lg font-bold text-white mb-2 line-clamp-2"
|
||||
data-video-id="${video.id}"
|
||||
data-play-url=""
|
||||
data-play-magnet=""
|
||||
>
|
||||
${safeTitle}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500">
|
||||
${new Date(video.created_at * 1000).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
${gearMenu}
|
||||
</div>
|
||||
${gearMenu}
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Clicking the card => open the video modal
|
||||
cardEl.addEventListener("click", () => {
|
||||
app.playVideoByEventId(video.id);
|
||||
if (showUnsupportedTorrentBadge) {
|
||||
cardEl.dataset.torrentSupported = "false";
|
||||
} 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);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
|
||||
attachHealthBadges(container);
|
||||
|
||||
window.app.videoList = container;
|
||||
window.app.attachVideoListHandler();
|
||||
|
||||
// Lazy-load images
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
lazyEls.forEach((el) => app.mediaLoader.observe(el));
|
||||
@@ -389,10 +507,15 @@ async function loadUserVideos(pubkey) {
|
||||
editBtns.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-edit-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-edit-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-revert-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-revert-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = parseInt(btn.getAttribute("data-delete-all-index"), 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) {
|
||||
@@ -446,43 +579,6 @@ function dedupeToNewestByRoot(videos) {
|
||||
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.
|
||||
*/
|
||||
|
||||
+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 {
|
||||
constructor() {
|
||||
// Initialize elements when the disclaimer HTML is in the DOM.
|
||||
this.init();
|
||||
this.modal = null;
|
||||
this.acceptButton = null;
|
||||
this.acceptHandler = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
this.modal = document.getElementById("disclaimerModal");
|
||||
this.acceptButton = document.getElementById("acceptDisclaimer");
|
||||
if (this.acceptButton) {
|
||||
this.acceptButton.addEventListener("click", () => {
|
||||
const nextAcceptButton = document.getElementById("acceptDisclaimer");
|
||||
|
||||
if (!this.acceptHandler) {
|
||||
this.acceptHandler = () => {
|
||||
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
|
||||
|
||||
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.)
|
||||
async function loadModal(url) {
|
||||
try {
|
||||
@@ -8,9 +67,14 @@ async function loadModal(url) {
|
||||
throw new Error("Failed to load " + url);
|
||||
}
|
||||
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
|
||||
.getElementById("modalContainer")
|
||||
.insertAdjacentHTML("beforeend", html);
|
||||
.insertAdjacentHTML("beforeend", sanitizedHtml);
|
||||
console.log(url, "loaded");
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -171,6 +235,12 @@ Promise.all([
|
||||
|
||||
// Also run once on initial load
|
||||
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() {
|
||||
console.log("handleHashChange called, current hash =", window.location.hash);
|
||||
|
||||
@@ -311,6 +378,7 @@ function handleHashChange() {
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
recordView("most-recent-videos");
|
||||
});
|
||||
});
|
||||
return;
|
||||
@@ -321,11 +389,11 @@ function handleHashChange() {
|
||||
|
||||
// Now dynamically load that partial, then call its init function
|
||||
import("./viewManager.js").then(({ loadView, viewInitRegistry }) => {
|
||||
loadView(viewUrl).then(() => {
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
});
|
||||
loadView(viewUrl).then(() => {
|
||||
const initFn = viewInitRegistry[viewName];
|
||||
if (typeof initFn === "function") {
|
||||
initFn();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const npubs = [
|
||||
"npub1m5s9w4t03znyetxswhgq0ud7fq8ef8y3l4kscn2e8wkvmv42hh3qujgjl3", // mister_monster
|
||||
"npub13qexjtmajssuhz8gdchgx65dwsnr705drse294zz5vt4e78ya2vqzyg8lv", // SatoshiSignal
|
||||
"npub1da7m2ksdj24995hm8afv88pjpvzt6t9vh70mg8t52yjwtxza3vjszyar58", // GoblinBox
|
||||
"npub196rl3tls3c4y79pc3ptrrj2430z7p5uwetfhukhtkt69hph0fvwq08l43q", // ~Bordut-Nodlex
|
||||
];
|
||||
|
||||
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
|
||||
|
||||
import { isDevMode } from "./config.js";
|
||||
import { ACCEPT_LEGACY_V1 } from "./constants.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
|
||||
@@ -14,6 +17,10 @@ const RELAY_URLS = [
|
||||
"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
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
@@ -43,53 +50,256 @@ function fakeDecrypt(encrypted) {
|
||||
return encrypted.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Nostr event => your "video" object.
|
||||
* CHANGED: skip if version <2
|
||||
*/
|
||||
function convertEventToVideo(event) {
|
||||
const EXTENSION_MIME_MAP = {
|
||||
mp4: "video/mp4",
|
||||
m4v: "video/x-m4v",
|
||||
webm: "video/webm",
|
||||
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 {
|
||||
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 isSupportedVersion = content.version >= 2;
|
||||
const hasRequiredFields = !!(content.title && content.magnet);
|
||||
const lastSegment = pathname.split("/").pop() || "";
|
||||
const match = lastSegment.match(/\.([a-z0-9]+)$/i);
|
||||
if (!match) {
|
||||
return "";
|
||||
}
|
||||
|
||||
if (!isSupportedVersion) {
|
||||
return {
|
||||
id: event.id,
|
||||
invalid: true,
|
||||
reason: "version <2",
|
||||
};
|
||||
const extension = match[1].toLowerCase();
|
||||
return EXTENSION_MIME_MAP[extension] || "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
invalid: true,
|
||||
reason: "missing title/magnet",
|
||||
};
|
||||
}
|
||||
|
||||
const directUrl = safeTrim(parsedContent.url);
|
||||
const directMagnetRaw = safeTrim(parsedContent.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 {
|
||||
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,
|
||||
invalid: true,
|
||||
reason: `unsupported version ${version}`,
|
||||
};
|
||||
} 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}`;
|
||||
}
|
||||
|
||||
export { convertEventToVideo };
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
@@ -118,6 +330,107 @@ class NostrClient {
|
||||
|
||||
// “activeMap” holds only the newest version for each root
|
||||
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() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
|
||||
this.restoreLocalData();
|
||||
|
||||
try {
|
||||
this.pool = new window.NostrTools.SimplePool();
|
||||
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() {
|
||||
return Promise.all(
|
||||
this.relays.map(
|
||||
@@ -212,8 +532,7 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a new video
|
||||
* CHANGED: Force version=2 for all new notes
|
||||
* Publish a new video using the v3 content schema.
|
||||
*/
|
||||
async publishVideo(videoData, pubkey) {
|
||||
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);
|
||||
}
|
||||
|
||||
let finalMagnet = videoData.magnet;
|
||||
if (videoData.isPrivate) {
|
||||
const rawMagnet = typeof videoData.magnet === "string" ? videoData.magnet : "";
|
||||
let finalMagnet = rawMagnet.trim();
|
||||
if (videoData.isPrivate && 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
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const contentObject = {
|
||||
version: 3,
|
||||
title: finalTitle,
|
||||
url: finalUrl,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: finalThumbnail,
|
||||
description: finalDescription,
|
||||
mode: videoData.mode || "live",
|
||||
videoRootId,
|
||||
version: 2, // forcibly set version=2
|
||||
deleted: false,
|
||||
isPrivate: videoData.isPrivate ?? false,
|
||||
title: videoData.title || "",
|
||||
magnet: finalMagnet,
|
||||
thumbnail: videoData.thumbnail || "",
|
||||
description: videoData.description || "",
|
||||
mode: videoData.mode || "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
created_at: createdAt,
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["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;
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||
@@ -322,14 +736,23 @@ class NostrClient {
|
||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||
}
|
||||
|
||||
const oldUrl = baseEvent.url || "";
|
||||
|
||||
// Determine if the updated note should be private
|
||||
const wantPrivate = updatedData.isPrivate ?? baseEvent.isPrivate ?? false;
|
||||
|
||||
// Use the new magnet if provided; otherwise, fall back to the decrypted old magnet
|
||||
let finalPlainMagnet = (updatedData.magnet || "").trim() || oldPlainMagnet;
|
||||
let finalMagnet = wantPrivate
|
||||
? fakeEncrypt(finalPlainMagnet)
|
||||
: finalPlainMagnet;
|
||||
const newMagnetValue =
|
||||
typeof updatedData.magnet === "string" ? updatedData.magnet.trim() : "";
|
||||
let finalPlainMagnet = newMagnetValue || oldPlainMagnet;
|
||||
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)
|
||||
const oldRootId = baseEvent.videoRootId || baseEvent.id;
|
||||
@@ -344,6 +767,7 @@ class NostrClient {
|
||||
deleted: false,
|
||||
isPrivate: wantPrivate,
|
||||
title: updatedData.title ?? baseEvent.title,
|
||||
url: finalUrl,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: updatedData.thumbnail ?? baseEvent.thumbnail,
|
||||
description: updatedData.description ?? baseEvent.description,
|
||||
@@ -420,6 +844,7 @@ class NostrClient {
|
||||
deleted: fetched.deleted,
|
||||
isPrivate: fetched.isPrivate,
|
||||
title: fetched.title,
|
||||
url: fetched.url,
|
||||
magnet: fetched.magnet,
|
||||
thumbnail: fetched.thumbnail,
|
||||
description: fetched.description,
|
||||
@@ -451,6 +876,7 @@ class NostrClient {
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
title: oldContent.title || "",
|
||||
url: "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This version was reverted by the creator.",
|
||||
@@ -530,6 +956,7 @@ class NostrClient {
|
||||
deleted: vid.deleted,
|
||||
isPrivate: vid.isPrivate,
|
||||
title: vid.title,
|
||||
url: vid.url,
|
||||
magnet: vid.magnet,
|
||||
thumbnail: vid.thumbnail,
|
||||
description: vid.description,
|
||||
@@ -548,12 +975,28 @@ class NostrClient {
|
||||
* Saves all known events to localStorage (or a different storage if you prefer).
|
||||
*/
|
||||
saveLocalData() {
|
||||
// Convert our allEvents map into a plain object for JSON storage
|
||||
const allEventsObject = {};
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
version: 1,
|
||||
savedAt: Date.now(),
|
||||
events: {},
|
||||
};
|
||||
|
||||
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", () => {
|
||||
if (isDevMode && invalidDuringSub.length > 0) {
|
||||
console.warn(
|
||||
`[subscribeVideos] found ${invalidDuringSub.length} invalid v2 notes:`,
|
||||
`[subscribeVideos] found ${invalidDuringSub.length} invalid video notes (with reasons):`,
|
||||
invalidDuringSub
|
||||
);
|
||||
}
|
||||
@@ -646,6 +1089,23 @@ class NostrClient {
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -701,7 +1161,7 @@ class NostrClient {
|
||||
// OPTIONAL: Log invalid stats
|
||||
if (invalidNotes.length > 0 && isDevMode) {
|
||||
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}`)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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*,
|
||||
@@ -120,6 +151,14 @@ class SubscriptionsManager {
|
||||
const plainObj = { subPubkeys: Array.from(this.subscribedPubkeys) };
|
||||
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 = "";
|
||||
try {
|
||||
cipherText = await window.nostr.nip04.encrypt(userPubkey, plainStr);
|
||||
@@ -234,7 +273,12 @@ class SubscriptionsManager {
|
||||
const container = document.getElementById(containerId);
|
||||
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 = `
|
||||
<p class="flex justify-center items-center h-full w-full text-center text-gray-500">
|
||||
No videos available yet.
|
||||
@@ -243,25 +287,27 @@ class SubscriptionsManager {
|
||||
}
|
||||
|
||||
// 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 fragment = document.createDocumentFragment();
|
||||
// Only declare localAuthorSet once
|
||||
const localAuthorSet = new Set();
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
dedupedVideos.forEach((video, index) => {
|
||||
if (!video.id || !video.title) {
|
||||
console.error("Missing ID or title:", video);
|
||||
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);
|
||||
|
||||
const nevent = window.NostrTools.nip19.neventEncode({ id: video.id });
|
||||
const shareUrl = `${window.location.pathname}?v=${encodeURIComponent(
|
||||
nevent
|
||||
)}`;
|
||||
const shareUrl = getAbsoluteShareUrl(nevent);
|
||||
const canEdit = window.app?.pubkey === video.pubkey;
|
||||
|
||||
const highlightClass =
|
||||
@@ -283,6 +329,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
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-event-id="${video.id}"
|
||||
>
|
||||
Revert
|
||||
</button>
|
||||
@@ -311,6 +358,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
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-event-id="${video.id}"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
@@ -318,6 +366,7 @@ class SubscriptionsManager {
|
||||
<button
|
||||
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-event-id="${video.id}"
|
||||
>
|
||||
Delete All
|
||||
</button>
|
||||
@@ -329,11 +378,43 @@ class SubscriptionsManager {
|
||||
|
||||
const safeTitle = window.app?.escapeHTML(video.title) || "Untitled";
|
||||
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 = `
|
||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
|
||||
<a
|
||||
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"
|
||||
>
|
||||
<div class="ratio-16-9">
|
||||
@@ -347,7 +428,9 @@ class SubscriptionsManager {
|
||||
<div class="p-4">
|
||||
<h3
|
||||
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}
|
||||
</h3>
|
||||
@@ -375,6 +458,7 @@ class SubscriptionsManager {
|
||||
</div>
|
||||
${gearMenu}
|
||||
</div>
|
||||
${connectionBadgesHtml}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -382,10 +466,52 @@ class SubscriptionsManager {
|
||||
const t = document.createElement("template");
|
||||
t.innerHTML = cardHtml.trim();
|
||||
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);
|
||||
});
|
||||
|
||||
container.appendChild(fragment);
|
||||
attachHealthBadges(container);
|
||||
|
||||
if (window.app) {
|
||||
window.app.videoList = container;
|
||||
window.app.attachVideoListHandler?.();
|
||||
}
|
||||
|
||||
// Lazy-load
|
||||
const lazyEls = container.querySelectorAll("[data-lazy]");
|
||||
@@ -407,10 +533,15 @@ class SubscriptionsManager {
|
||||
editButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-edit-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-edit-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-revert-index");
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-revert-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) => {
|
||||
btn.addEventListener("click", (ev) => {
|
||||
ev.stopPropagation();
|
||||
const idx = btn.getAttribute("data-delete-all-index");
|
||||
const dd = document.getElementById(`settingsDropdown-${idx}`);
|
||||
const idxAttr = btn.getAttribute("data-delete-all-index");
|
||||
const idx = Number.parseInt(idxAttr, 10);
|
||||
const dd = document.getElementById(`settingsDropdown-${idxAttr}`);
|
||||
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) {
|
||||
try {
|
||||
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 };
|
||||
}
|
||||
return sharedConvertEventToVideo(evt);
|
||||
}
|
||||
|
||||
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 { subscriptions } from "./subscriptions.js";
|
||||
|
||||
const TRACKING_SCRIPT_PATTERN = /(?:^|\/)tracking\.js(?:$|\?)/;
|
||||
|
||||
/**
|
||||
* Load a partial view by URL into the #viewContainer.
|
||||
*/
|
||||
@@ -23,6 +25,10 @@ export async function loadView(viewUrl) {
|
||||
// Copy and execute any inline scripts
|
||||
const scriptTags = doc.querySelectorAll("script");
|
||||
scriptTags.forEach((oldScript) => {
|
||||
const src = oldScript.getAttribute("src") || "";
|
||||
if (src && TRACKING_SCRIPT_PATTERN.test(src)) {
|
||||
return;
|
||||
}
|
||||
const newScript = document.createElement("script");
|
||||
Array.from(oldScript.attributes).forEach((attr) => {
|
||||
newScript.setAttribute(attr.name, attr.value);
|
||||
@@ -44,6 +50,9 @@ export const viewInitRegistry = {
|
||||
"most-recent-videos": () => {
|
||||
if (window.app && window.app.loadVideos) {
|
||||
window.app.videoList = document.getElementById("videoList");
|
||||
if (window.app.attachVideoListHandler) {
|
||||
window.app.attachVideoListHandler();
|
||||
}
|
||||
window.app.loadVideos();
|
||||
}
|
||||
// Force profile updates after the new view is in place.
|
||||
|
||||
+477
-16
@@ -2,11 +2,145 @@
|
||||
|
||||
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 {
|
||||
constructor() {
|
||||
// Reusable objects and flags
|
||||
this.client = null;
|
||||
this.currentTorrent = null;
|
||||
this.probeClient = null;
|
||||
|
||||
// Service worker registration is cached
|
||||
this.swRegistration = null;
|
||||
@@ -16,6 +150,155 @@ export class TorrentClient {
|
||||
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) {
|
||||
console.log(msg);
|
||||
}
|
||||
@@ -43,6 +326,13 @@ export class TorrentClient {
|
||||
// 2) If we haven’t registered the service worker yet, do it now
|
||||
if (!this.swRegistration) {
|
||||
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() {
|
||||
try {
|
||||
const isBraveBrowser = await this.isBrave();
|
||||
@@ -92,7 +493,15 @@ export class TorrentClient {
|
||||
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) {
|
||||
this.log("Checking Brave configuration...");
|
||||
if (!navigator.serviceWorker) {
|
||||
@@ -160,6 +569,16 @@ export class TorrentClient {
|
||||
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
|
||||
registration.update();
|
||||
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
|
||||
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) => {
|
||||
if (err && typeof err.message === "string") {
|
||||
if (
|
||||
@@ -203,18 +644,20 @@ export class TorrentClient {
|
||||
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.addEventListener("error", (e) => {
|
||||
this.log("Video error:", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
videoElement.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
this.attemptAutoplay(videoElement, "chrome");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
try {
|
||||
file.streamTo(videoElement);
|
||||
@@ -240,18 +683,20 @@ export class TorrentClient {
|
||||
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.addEventListener("error", (e) => {
|
||||
this.log("Video error (Firefox path):", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
videoElement.addEventListener(
|
||||
"canplay",
|
||||
() => {
|
||||
this.attemptAutoplay(videoElement, "firefox");
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
|
||||
try {
|
||||
file.streamTo(videoElement, { highWaterMark: 256 * 1024 });
|
||||
@@ -272,7 +717,7 @@ export class TorrentClient {
|
||||
* Initiates streaming of a torrent magnet to a <video> element.
|
||||
* Ensures the service worker is set up only once and the client is reused.
|
||||
*/
|
||||
async streamVideo(magnetURI, videoElement) {
|
||||
async streamVideo(magnetURI, videoElement, opts = {}) {
|
||||
try {
|
||||
// 1) Make sure we have a WebTorrent client and a valid SW registration.
|
||||
await this.init();
|
||||
@@ -288,6 +733,16 @@ export class TorrentClient {
|
||||
}
|
||||
|
||||
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) => {
|
||||
// 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.client.add(
|
||||
magnetURI,
|
||||
{ strategy: "sequential", maxWebConns: 4 },
|
||||
{ ...chromeOptions, maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||
@@ -303,7 +758,7 @@ export class TorrentClient {
|
||||
);
|
||||
} else {
|
||||
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.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||
});
|
||||
@@ -330,6 +785,10 @@ export class TorrentClient {
|
||||
await this.client.destroy();
|
||||
this.client = null;
|
||||
}
|
||||
if (this.probeClient) {
|
||||
await this.probeClient.destroy();
|
||||
this.probeClient = null;
|
||||
}
|
||||
this.currentTorrent = null;
|
||||
this.serverCreated = false;
|
||||
} catch (error) {
|
||||
@@ -338,4 +797,6 @@ export class TorrentClient {
|
||||
}
|
||||
}
|
||||
|
||||
TorrentClient.PROBE_TRACKERS = DEFAULT_PROBE_TRACKERS;
|
||||
|
||||
export const torrentClient = new TorrentClient();
|
||||
@@ -15,6 +15,9 @@
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
<meta charset="utf-8" />
|
||||
<title>Nostr Ephemeral DM Tester (Using finalizeEvent)</title>
|
||||
<script src="../js/tracking.js" defer></script>
|
||||
<style>
|
||||
/* Basic styling for the demo page */
|
||||
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" />
|
||||
|
||||
<!-- Scripts: webtorrent, angular, etc. -->
|
||||
<script src="../js/tracking.js" defer></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>-->
|
||||
|
||||
|
||||
@@ -43,7 +43,21 @@
|
||||
|
||||
<!-- Area for Subscribe/Unsubscribe button -->
|
||||
<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>
|
||||
|
||||
<hr class="my-6" />
|
||||
|
||||
Reference in new issue
Block a user