mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 06:58:43 +00:00
Merge pull request #2 from PR0M3TH3AN/unstable
Made a ton of changes. IDK. seems to work well.
This commit is contained in:
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M24 13.616v-3.232c-1.651-.587-2.694-.752-3.219-2.019v-.001c-.527-1.271.1-2.134.847-3.707l-2.285-2.285c-1.561.742-2.433 1.375-3.707.847h-.001c-1.269-.526-1.435-1.576-2.019-3.219h-3.232c-.582 1.635-.749 2.692-2.019 3.219h-.001c-1.271.528-2.132-.098-3.707-.847l-2.285 2.285c.745 1.568 1.375 2.434.847 3.707-.527 1.271-1.584 1.438-3.219 2.02v3.232c1.632.58 2.692.749 3.219 2.019.53 1.282-.114 2.166-.847 3.707l2.285 2.286c1.562-.743 2.434-1.375 3.707-.847h.001c1.27.526 1.436 1.579 2.019 3.219h3.232c.582-1.636.75-2.69 2.027-3.222h.001c1.262-.524 2.12.101 3.698.851l2.285-2.286c-.744-1.563-1.375-2.433-.848-3.706.527-1.271 1.588-1.44 3.221-2.021zm-12 2.384c-2.209 0-4-1.791-4-4s1.791-4 4-4 4 1.791 4 4-1.791 4-4 4z"/></svg>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
|
||||
<path d="M24,13.616L24,10.384C22.349,9.797 21.306,9.632 20.781,8.365L20.781,8.364C20.254,7.093 20.881,6.23 21.628,4.657L19.343,2.372C17.782,3.114 16.91,3.747 15.636,3.219L15.635,3.219C14.366,2.693 14.2,1.643 13.616,0L10.384,0C9.802,1.635 9.635,2.692 8.365,3.219L8.364,3.219C7.093,3.747 6.232,3.121 4.657,2.372L2.372,4.657C3.117,6.225 3.747,7.091 3.219,8.364C2.692,9.635 1.635,9.802 0,10.384L0,13.616C1.632,14.196 2.692,14.365 3.219,15.635C3.749,16.917 3.105,17.801 2.372,19.342L4.657,21.628C6.219,20.885 7.091,20.253 8.364,20.781L8.365,20.781C9.635,21.307 9.801,22.36 10.384,24L13.616,24C14.198,22.364 14.366,21.31 15.643,20.778L15.644,20.778C16.906,20.254 17.764,20.879 19.342,21.629L21.627,19.343C20.883,17.78 20.252,16.91 20.779,15.637C21.306,14.366 22.367,14.197 24,13.616ZM12,16C9.791,16 8,14.209 8,12C8,9.791 9.791,8 12,8C14.209,8 16,9.791 16,12C16,14.209 14.209,16 12,16Z" style="fill:white;fill-rule:nonzero;"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 811 B After Width: | Height: | Size: 1.3 KiB |
64
src/components/profile-modal.html
Normal file
64
src/components/profile-modal.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- components/profile-modal.html -->
|
||||
<div id="profileModal" class="fixed inset-0 bg-black/90 z-50 hidden">
|
||||
<div
|
||||
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-sm my-8 rounded-lg overflow-hidden relative flex flex-col"
|
||||
>
|
||||
<!-- Modal Header -->
|
||||
<div
|
||||
class="sticky top-0 bg-gradient-to-b from-black/80 to-transparent p-4 flex items-center justify-between"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Profile</h2>
|
||||
<button
|
||||
id="closeProfileModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Body -->
|
||||
<div class="p-6 flex-1 text-gray-200 space-y-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
<img
|
||||
id="profileModalAvatar"
|
||||
src="assets/jpg/default-profile.jpg"
|
||||
alt="Profile"
|
||||
class="w-16 h-16 object-cover rounded-full"
|
||||
/>
|
||||
<p id="profileModalName" class="text-lg font-semibold truncate">
|
||||
Loading...
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-400">
|
||||
Some future profile details or settings could go here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="p-4 border-t border-gray-700 flex items-center justify-end">
|
||||
<button
|
||||
id="profileLogoutBtn"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
114
src/components/upload-modal.html
Normal file
114
src/components/upload-modal.html
Normal file
@@ -0,0 +1,114 @@
|
||||
<!-- components/upload-modal.html -->
|
||||
<div id="uploadModal" class="fixed inset-0 bg-black/90 z-50 hidden">
|
||||
<div
|
||||
class="modal-container h-full w-full flex items-start justify-center overflow-y-auto"
|
||||
>
|
||||
<div
|
||||
class="modal-content bg-gray-900 w-full max-w-lg my-8 rounded-lg overflow-hidden relative"
|
||||
>
|
||||
<!-- 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"
|
||||
>
|
||||
<h2 class="text-xl font-bold text-white">Share a Video</h2>
|
||||
<button
|
||||
id="closeUploadModal"
|
||||
class="flex items-center justify-center w-10 h-10 rounded-full bg-black/50 hover:bg-black/70 transition-all duration-200 backdrop-blur focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-black focus:ring-blue-500"
|
||||
>
|
||||
<!-- X or arrow icon -->
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-6 h-6 text-gray-300"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Form Content -->
|
||||
<div class="p-6 space-y-4">
|
||||
<form id="uploadForm" class="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
for="uploadTitle"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="uploadTitle"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadMagnet"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Magnet Link</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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadThumbnail"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Thumbnail URL (optional)</label
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
id="uploadThumbnail"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="uploadDescription"
|
||||
class="block text-sm font-medium text-gray-200"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="uploadDescription"
|
||||
rows="3"
|
||||
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"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="uploadIsPrivate"
|
||||
class="form-checkbox h-5 w-5 text-blue-500"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-200"
|
||||
>Private Listing (Encrypt Magnet)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Publish
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@@ -1,329 +0,0 @@
|
||||
# **bitvid: Enhanced Multi-View Architecture Migration Plan**
|
||||
|
||||
This plan describes how to transform your current `index.html` file so that different sections of content are loaded as separate views. It keeps the header and footer consistent on each view, while the center portion of the page switches among “most recent videos,” user profiles, trending feeds, and more. Future features like personalized feeds or channel pages can be added following the same approach.
|
||||
|
||||
---
|
||||
|
||||
## **1. Goals**
|
||||
|
||||
1. **Preserve Navigation Bar & Footer**
|
||||
Keep the top navigation (logo, login/logout, “add video” controls) and the bottom footer in `index.html` at all times.
|
||||
|
||||
2. **Separate the Content Grid**
|
||||
Move the existing video grid or “recent videos” listing into its own file, for example `views/most-recent-videos.html`. You will load this content into a main container within `index.html`.
|
||||
|
||||
3. **Handle Additional Views**
|
||||
Prepare to load other views (profiles, trending, personalized feeds) in the same container. Each view can be its own HTML snippet or partial, stored separately (e.g. `views/profile-view.html`, `views/trending.html`, etc.).
|
||||
|
||||
4. **Single-Page Navigation**
|
||||
Use JavaScript to switch or load the correct view based on the URL. This keeps the user on a single page, but updates what they see in the main section.
|
||||
|
||||
5. **Maintain Existing Modal**
|
||||
The video-modal (`video-modal.html`) will remain a separate file, loaded into the DOM as is. This ensures consistent playback.
|
||||
|
||||
---
|
||||
|
||||
## **2. Proposed File Structure**
|
||||
|
||||
Below is an example layout. You do not need to follow it exactly, but it helps you see where each piece goes.
|
||||
|
||||
```
|
||||
project/
|
||||
├─ index.html
|
||||
├─ components/
|
||||
│ └─ video-modal.html
|
||||
├─ views/
|
||||
│ ├─ most-recent-videos.html
|
||||
│ ├─ profile-view.html
|
||||
│ ├─ trending.html
|
||||
│ └─ ...
|
||||
├─ js/
|
||||
│ ├─ app.js
|
||||
│ ├─ nostr.js
|
||||
│ ├─ webtorrent.js
|
||||
│ ├─ ...
|
||||
│ └─ viewManager.js <-- new file for handling view loading
|
||||
├─ css/
|
||||
│ └─ style.css
|
||||
└─ ...
|
||||
```
|
||||
|
||||
1. **`index.html`**
|
||||
- Contains the header, top nav, login/logout, plus the footer.
|
||||
- Has a single `<div>` where content from your partial views will be loaded.
|
||||
|
||||
2. **`views/most-recent-videos.html`**
|
||||
- Contains only the HTML (and minimal inline scripts) for the grid of most recent videos.
|
||||
- No header or footer.
|
||||
- No scripts for Nostr or WebTorrent—those remain in your main JS files.
|
||||
|
||||
3. **Other Views** (optional)
|
||||
- Similar structure to `most-recent-videos.html`.
|
||||
- Example: `profile-view.html`, `trending.html`, etc.
|
||||
|
||||
4. **`video-modal.html`**
|
||||
- Remains a separate component file for the modal.
|
||||
- Inserted into the DOM in `index.html` or on demand, as you already do.
|
||||
|
||||
5. **`viewManager.js`** (new optional file)
|
||||
- Manages the logic of fetching these partial view files and inserting them into the page container.
|
||||
- Handles route changes to decide which view to load.
|
||||
|
||||
---
|
||||
|
||||
## **3. Modifying `index.html`**
|
||||
|
||||
Below is a suggested strategy for `index.html`:
|
||||
|
||||
1. **Keep the current `<header>`**
|
||||
It has your logo and login/logout UI.
|
||||
|
||||
2. **Keep the current `<footer>`**
|
||||
It has the links to GitHub, Nostr, blog, and so on.
|
||||
|
||||
3. **Replace the big video listing area with a single container**
|
||||
For example:
|
||||
```html
|
||||
<div id="viewContainer" class="flex-grow">
|
||||
<!-- Dynamically loaded view content goes here -->
|
||||
</div>
|
||||
```
|
||||
|
||||
4. **Move the “most recent videos” grid**
|
||||
- Copy that section (including the `<div id="videoList">...</div>`) into `views/most-recent-videos.html`.
|
||||
- You can remove it from the main `index.html`, leaving only your `<header>`, login controls, disclaimers, and `<footer>`.
|
||||
|
||||
5. **Load the newly created partial**
|
||||
- In your JavaScript, on page load, fetch `views/most-recent-videos.html` via `fetch()` and place it inside `#viewContainer`.
|
||||
- Example:
|
||||
```js
|
||||
async function loadMostRecentVideosView() {
|
||||
const res = await fetch('views/most-recent-videos.html');
|
||||
const html = await res.text();
|
||||
document.getElementById('viewContainer').innerHTML = html;
|
||||
// Then re-initialize anything needed (e.g. event listeners, etc.)
|
||||
}
|
||||
```
|
||||
- This keeps the same content, but it’s now in a separate file.
|
||||
|
||||
6. **Keep the existing disclaimers and disclaimers modal**
|
||||
- The disclaimer modal can stay in `index.html` since it is site-wide.
|
||||
- The same applies to the video player modal. You can keep a `<div id="modalContainer"></div>` to insert `video-modal.html`, or load it separately.
|
||||
|
||||
---
|
||||
|
||||
## **4. JavaScript for View Switching**
|
||||
|
||||
### **4.1 Single-File Approach**
|
||||
|
||||
You could place view-loading code in `app.js`. For example:
|
||||
|
||||
```js
|
||||
// app.js
|
||||
async function showView(viewName) {
|
||||
let viewUrl = '';
|
||||
switch (viewName) {
|
||||
case 'recent':
|
||||
viewUrl = 'views/most-recent-videos.html';
|
||||
break;
|
||||
case 'profile':
|
||||
viewUrl = 'views/profile-view.html';
|
||||
break;
|
||||
// etc.
|
||||
default:
|
||||
viewUrl = 'views/most-recent-videos.html';
|
||||
break;
|
||||
}
|
||||
|
||||
const res = await fetch(viewUrl);
|
||||
const html = await res.text();
|
||||
document.getElementById('viewContainer').innerHTML = html;
|
||||
|
||||
// Re-initialize any needed scripts for the new view
|
||||
}
|
||||
```
|
||||
|
||||
Then when the page loads, you do `showView('recent');`
|
||||
|
||||
### **4.2 Dedicated `viewManager.js`**
|
||||
|
||||
Alternatively, create a separate file (`viewManager.js`) with functions like:
|
||||
|
||||
```js
|
||||
export async function loadView(viewUrl, containerId = 'viewContainer') {
|
||||
const res = await fetch(viewUrl);
|
||||
const html = await res.text();
|
||||
document.getElementById(containerId).innerHTML = html;
|
||||
}
|
||||
```
|
||||
|
||||
Then in your main app code, call `loadView('views/most-recent-videos.html');`.
|
||||
|
||||
---
|
||||
|
||||
## **5. Preserving Existing Functionality**
|
||||
|
||||
1. **Form Submission**
|
||||
Your “Share Video” form can remain in `index.html` or be placed within a dedicated “upload video” view. If you do move it, you’ll just need to ensure the form’s JS event listeners are attached once the view loads.
|
||||
|
||||
2. **Login/Logout**
|
||||
The login button and user status references can remain in the header. This code continues to be managed by `app.js` without changes, as it is global.
|
||||
|
||||
3. **Video List**
|
||||
Since the “most recent videos” grid moves to a partial, the original `<div id="videoList">...</div>` is replaced by a container in `most-recent-videos.html`. After loading that partial, your existing script logic for populating the video list (like `app.renderVideoList()`) will still work. You just need to ensure the new partial has the same IDs.
|
||||
|
||||
4. **Modal**
|
||||
The `video-modal.html` can stay a separate component. You already fetch and inject it into the DOM. Nothing changes there, aside from making sure the container is in `index.html`, so the modal can appear on top of whichever view the user is in.
|
||||
|
||||
5. **Disclaimer Modal**
|
||||
Similar approach. It can stay in `index.html` or be a partial if you prefer. Keep using the same logic to display it on first load.
|
||||
|
||||
---
|
||||
|
||||
## **6. Routing for Future Features**
|
||||
|
||||
### **6.1 Hash Routing**
|
||||
|
||||
As your platform grows, you may want a URL like `/#/profile/npub123` for user profiles. In that case:
|
||||
|
||||
1. **Listen for `hashchange`**:
|
||||
```js
|
||||
window.addEventListener('hashchange', handleRouting);
|
||||
```
|
||||
2. **Parse the hash**:
|
||||
```js
|
||||
function handleRouting() {
|
||||
const hash = window.location.hash; // e.g. "#/profile/npub123"
|
||||
if (hash.startsWith('#/profile/')) {
|
||||
const parts = hash.split('/');
|
||||
const npub = parts[2];
|
||||
loadProfileView(npub);
|
||||
} else {
|
||||
// default
|
||||
showView('recent');
|
||||
}
|
||||
}
|
||||
```
|
||||
3. **Load partial** (e.g. `profile-view.html`), then run logic to fetch user’s videos.
|
||||
|
||||
### **6.2 Future Sections**
|
||||
|
||||
- **Trending:** `/#/trending`
|
||||
- **Personalized:** `/#/for-you`
|
||||
- **Channel:** `/#/channel/someid`
|
||||
|
||||
Each route calls the correct partial load function, then re-initializes the data fetch and rendering.
|
||||
|
||||
---
|
||||
|
||||
## **7. Example of New `index.html` Structure**
|
||||
|
||||
A simplified version (in concept):
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<!-- meta, styles, etc. -->
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<!-- Container for the entire site -->
|
||||
<div id="app" class="container mx-auto px-4 py-8 min-h-screen flex flex-col">
|
||||
|
||||
<!-- Header / Nav -->
|
||||
<header class="mb-8">
|
||||
<div class="flex items-start">
|
||||
<!-- Logo -->
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
/>
|
||||
</div>
|
||||
<!-- Buttons: login, logout, etc. -->
|
||||
</header>
|
||||
|
||||
<!-- Error and success containers, disclaimers, etc. -->
|
||||
<!-- ... -->
|
||||
|
||||
<!-- Main content container for dynamic views -->
|
||||
<main id="viewContainer" class="flex-grow">
|
||||
<!-- This is where we load something like most-recent-videos.html -->
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="mt-auto pb-8 text-center">
|
||||
<!-- Footer links, contact info, IPNS, etc. -->
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Modal for video player -->
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- Scripts -->
|
||||
<!-- Example:
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
<script type="module" src="js/app.js"></script>
|
||||
-->
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **8. Step-by-Step Migration**
|
||||
|
||||
1. **Create `views/most-recent-videos.html`**
|
||||
- Copy the entire grid section from your current `index.html` into this file.
|
||||
- Keep the same IDs (`videoList`, etc.) so the existing code that populates it still works.
|
||||
|
||||
2. **Replace the Original Grid in `index.html`**
|
||||
- Remove that big chunk of HTML, leaving just `<div id="viewContainer"></div>`.
|
||||
|
||||
3. **Load the Partial**
|
||||
- On your page initialization in `app.js` (or a new `viewManager.js`), run a function to fetch `most-recent-videos.html` and inject it into `viewContainer`.
|
||||
```js
|
||||
import { loadView } from './viewManager.js';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadView('views/most-recent-videos.html');
|
||||
// Then call your existing code to load videos, etc.
|
||||
});
|
||||
```
|
||||
|
||||
4. **Re-link Any JS Event Listeners**
|
||||
- After loading the partial, you might need to re-run any code that attaches event listeners to elements like `#videoList`. If your existing code is triggered on DOMContentLoaded, it should be aware that some elements appear later.
|
||||
|
||||
5. **Check Modal**
|
||||
- Make sure that your code for injecting `video-modal.html` or referencing it still points to the correct container (`modalContainer`). That part likely won’t change.
|
||||
|
||||
6. **Future Views**
|
||||
- Create additional partial files (e.g., `profile-view.html`, `trending.html`) using the same approach.
|
||||
- Expand your router logic to load different views based on the URL.
|
||||
|
||||
---
|
||||
|
||||
## **9. Potential Enhancements**
|
||||
|
||||
1. **Deep Linking**
|
||||
- Use URLs to direct users to specific videos, profiles, or sections.
|
||||
- For example, a link like `bitvid.com/#/profile/npub1234` opens that user’s channel.
|
||||
|
||||
2. **Templating Libraries**
|
||||
- If your views become complex, you might adopt a minimal client-side templating approach or even a lightweight framework.
|
||||
- For now, simple partial HTML with `fetch()` is enough.
|
||||
|
||||
3. **Reusable Components**
|
||||
- Create a folder for shared components (like your disclaimers, video card layout, or new sidebars).
|
||||
- Load them when needed, or store them as `<template>` elements that can be cloned into the DOM.
|
||||
|
||||
4. **Animation / Transition**
|
||||
- Add simple fade-in or slide-in effects when swapping views to make the UI more polished.
|
||||
|
||||
---
|
||||
|
||||
## **10. Summary**
|
||||
|
||||
By isolating the main grid into `most-recent-videos.html` (and doing the same for future content sections), you’ll keep `index.html` focused on the site-wide header, footer, and scripts. This approach makes it easy to add new views (trending, user profiles, etc.) without cluttering the main file. Your existing functionality—like the video modal, login system, and disclaimers—remains intact and available across all views. Over time, you can add routing for advanced sections such as personalized feeds or advanced channel pages, all while loading them into the same container.
|
||||
|
||||
In short, this plan preserves the existing UI elements that should remain global (header, footer) and relocates the content grid into a dedicated partial. It positions you to grow the platform with more views and a clean single-page architecture.
|
@@ -1,193 +1,90 @@
|
||||
# **bitvid: Enhanced Nostr Video/Audio Note Specification: Version 3**
|
||||
|
||||
This document updates the existing Version 2 specification by adding optional fields for adult content, multiple categories, extended metadata, audio/podcast features, and more. These changes remain backward-compatible so Version 2 clients can continue to display basic information.
|
||||
This specification updates the previous version (Version 2) and introduces:
|
||||
|
||||
1. **videoRootId**: A dedicated field in the JSON content to group related edits and deletes under one “root” post.
|
||||
2. **Support for Audio/Podcast**: Additional fields for music/podcast metadata.
|
||||
3. **Adult Content, Multi-Category, and Extended Metadata**: Optional fields to handle specialized use cases.
|
||||
4. **Backward Compatibility**: Clients implementing Version 2 can still display the basic fields.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## **1. Overview**
|
||||
|
||||
Nostr posts use **kind = 30078**, with a JSON structure stored in the `content` field. Version 3 retains all fields from Version 2 while adding new ones for richer functionality, easier filtering, and support for audio content.
|
||||
Nostr posts of **kind = 30078** are used for sharing media (videos, audio, podcasts, etc.). The JSON payload goes into the `content` field, while tags array can store quick references like `["t", "video"]` or `["d", "<unique-id>"]`.
|
||||
|
||||
### **Key Concepts**
|
||||
|
||||
- **videoRootId**: A unique identifier stored in the `content` JSON.
|
||||
- Ensures multiple edits or a delete event are recognized as referring to the same underlying post.
|
||||
- If missing (legacy posts), clients may fall back to the d-tag or event ID to group events, but using `videoRootId` is strongly recommended for consistent overshadow logic.
|
||||
- **Backward Compatibility**: All newly introduced fields are optional. Version 2 clients see fields like `title`, `magnet`, `description`, but ignore new fields such as `adult`, `audioQuality`, etc.
|
||||
|
||||
---
|
||||
|
||||
## General Format
|
||||
## **2. Event Structure**
|
||||
|
||||
A typical note follows this format:
|
||||
A typical event:
|
||||
|
||||
| **Field** | **Type** | **Description** |
|
||||
|---------------|----------------|--------------------------------------------------------------------|
|
||||
| `kind` | Integer | Fixed as `30078` for media-sharing events (video, music, etc.). |
|
||||
| `pubkey` | String | Public key of the note creator. |
|
||||
| `created_at` | Integer | Unix timestamp (seconds). |
|
||||
| `tags` | Array of Arrays| Includes metadata such as `["t", "video"]` or `["t", "music"]` and `["d", "<id>"]`. |
|
||||
| `content` | JSON String | JSON object containing the post data (detailed below). |
|
||||
| **Field** | **Type** | **Description** |
|
||||
| ------------ | ------------- | ------------------------------------------------------------------------------------------ |
|
||||
| `kind` | Integer | Fixed at `30078` for these media notes. |
|
||||
| `pubkey` | String | Creator’s pubkey in hex. |
|
||||
| `created_at` | Integer | Unix timestamp (seconds). |
|
||||
| `tags` | Array | Includes metadata such as `["t", "video"]` or `["t", "music"]` and `["d", "<unique-id>"]`. |
|
||||
| `content` | String (JSON) | JSON specifying metadata (see table below). |
|
||||
|
||||
---
|
||||
|
||||
## Post Content for Version 3
|
||||
## **3. Version 3 Content JSON**
|
||||
|
||||
| **Field** | **Type** | **Description** |
|
||||
|-------------------|------------------------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| `version` | Integer | Now set to `3`. |
|
||||
| `deleted` | Boolean | Indicates soft deletion. |
|
||||
| `isPrivate` | Boolean | `true` if content is private (magnet and certain optional fields may be encrypted). |
|
||||
| `title` | String | Title of the media (video title, track title, podcast episode, etc.). |
|
||||
| `magnet` | String | Magnet link for the primary media file (encrypted if `isPrivate = true`). |
|
||||
| `extraMagnets` | Array (optional) | Additional magnet links for multiple resolutions/versions (commonly used for video but can be used for audio too). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image. |
|
||||
| `description` | String (optional) | Description of the media. |
|
||||
| `mode` | String | Indicates `live` or `dev` mode for streaming or test scenarios. |
|
||||
| `adult` | Boolean (optional) | `true` if content is adult-only. Default is `false` or omitted. |
|
||||
| `categories` | Array (optional) | A list of categories or tags, e.g., `["comedy", "music"]`. |
|
||||
| `language` | String (optional) | Language code (e.g., `en`, `es`). |
|
||||
| `payment` | String (optional) | Monetization field, such as a Lightning address. |
|
||||
| `i18n` | Object (optional) | Holds internationalized fields (e.g., `{"title_en": "Hello", "title_es": "Hola"}`). |
|
||||
| `encryptedMeta` | Boolean (optional) | If `true`, indicates fields like `description` or `thumbnail` may be encrypted. |
|
||||
| **Audio/Podcast-Specific Fields** | | |
|
||||
| `contentType` | String (optional) | Type of media, e.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
||||
| `albumName` | String (optional) | Name of the album (if part of a music album). |
|
||||
| `trackNumber` | Integer (optional) | Track number in an album. |
|
||||
| `trackTitle` | String (optional) | Track title if different from `title`. |
|
||||
| `podcastSeries` | String (optional) | Name of the podcast series. |
|
||||
| `seasonNumber` | Integer (optional) | Season number for a podcast. |
|
||||
| `episodeNumber` | Integer (optional) | Episode number for a podcast series. |
|
||||
| `duration` | Integer (optional) | Duration in seconds (useful for audio or video players). |
|
||||
| `artistName` | String (optional) | Main artist or presenter. |
|
||||
| `contributors` | Array (optional) | List of additional contributors, e.g., `[{"name": "John", "role": "Producer"}]`. |
|
||||
| `audioQuality` | Array (optional) | Multiple magnet links with different audio formats or bitrates. Example: `[{"quality": "lossless", "magnet": "..."}]`.|
|
||||
| `playlist` | Array (optional) | Array of magnet links or references forming a playlist (useful for albums or sets). |
|
||||
| **Field** | **Type** | **Description** |
|
||||
| --------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `videoRootId` | String (optional) | A stable root ID used to link multiple versions (edits) and a delete event together. **Recommended** for overshadow logic. |
|
||||
| `version` | Integer | Now set to `3`. |
|
||||
| `deleted` | Boolean | `true` marks the post as “soft deleted.” |
|
||||
| `isPrivate` | Boolean | Indicates if `magnet` (and possibly other fields) are encrypted. |
|
||||
| `title` | String | Display title for the media. |
|
||||
| `magnet` | String | Magnet link for primary media (encrypted if `isPrivate = true`). |
|
||||
| `extraMagnets` | Array (optional) | Additional magnet links (e.g., multiple resolutions). |
|
||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `description` | String (optional) | A textual description (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||
| `mode` | String | Typically `live` or `dev`. |
|
||||
| `adult` | Boolean (optional) | `true` if content is adult-only. Default: `false` or omitted. |
|
||||
| `categories` | Array (optional) | Array of categories, e.g. `["comedy", "music"]`. |
|
||||
| `language` | String (optional) | Language code (e.g. `"en"`, `"es"`). |
|
||||
| `payment` | String (optional) | Monetization field (e.g. a Lightning address). |
|
||||
| `i18n` | Object (optional) | Internationalization map (e.g. `{"title_en": "...", "description_es": "..."}`). |
|
||||
| `encryptedMeta` | Boolean (optional) | Indicates if fields like `description` or `thumbnail` are encrypted. |
|
||||
| **Audio/Podcast-Specific Fields** | | |
|
||||
| `contentType` | String (optional) | E.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
||||
| `albumName` | String (optional) | Name of the album (for music). |
|
||||
| `trackNumber` | Integer (optional) | Track number in an album. |
|
||||
| `trackTitle` | String (optional) | Track title if different from `title`. |
|
||||
| `podcastSeries` | String (optional) | Name of the podcast series. |
|
||||
| `seasonNumber` | Integer (optional) | Season number for a podcast. |
|
||||
| `episodeNumber` | Integer (optional) | Episode number for a podcast series. |
|
||||
| `duration` | Integer (optional) | Duration in seconds (useful for audio or video players). |
|
||||
| `artistName` | String (optional) | Artist or presenter name. |
|
||||
| `contributors` | Array (optional) | List of additional contributors, e.g. `[{"name": "X", "role": "Producer"}]`. |
|
||||
| `audioQuality` | Array (optional) | Array of objects indicating different audio bitrates/formats, each with a magnet link. |
|
||||
| `playlist` | Array (optional) | For multi-track or multi-episode sets, e.g. `[ "magnet1", "magnet2" ]`. |
|
||||
|
||||
> **Note**: All fields except `version`, `deleted`, `isPrivate`, `title`, `magnet`, and `mode` are optional. You can omit fields that are not relevant to your post.
|
||||
> **Note**: All fields except `version`, `deleted`, `title`, `magnet`, and `videoRootId` are optional. If an older post lacks `videoRootId`, fallback grouping may rely on the `d` tag or event ID.
|
||||
|
||||
---
|
||||
|
||||
## Tagging
|
||||
## **4. Using `videoRootId`**
|
||||
|
||||
### `tags`
|
||||
- Purpose: Quick lookups for content type, unique IDs, or user-defined categories.
|
||||
- Examples:
|
||||
- `["t", "video"]` — Type indicator for videos.
|
||||
- `["t", "music"]` — Type indicator for audio/music.
|
||||
- `["d", "unique-identifier"]` — Unique ID for direct references.
|
||||
- `["adult", "true"]` — Optional. Some clients may store adult flags here rather than in `content`.
|
||||
- **Purpose**: Ensures all edits and a final delete event share the same “root.”
|
||||
- **Edit**: Keep the same `videoRootId` in the new content so clients know it’s an update of the same item.
|
||||
- **Delete**: Reuse the same `videoRootId` (and typically the same `d` tag). Mark `deleted = true` to overshadow the old event.
|
||||
|
||||
No changes are required for Version 3 tagging, but more detailed tags (e.g., `"music"`, `"podcast"`, or `"audiobook"`) can be used to help clients sort or filter specific media.
|
||||
**Fallback**: Legacy or older notes might not have a `videoRootId`. Clients can group them by `["d", "<id>"]` or treat the old event’s own ID as its “root.” However, for new posts, **always** set `videoRootId`.
|
||||
|
||||
---
|
||||
|
||||
## Behavior Based on Privacy
|
||||
|
||||
### Public Posts
|
||||
- `isPrivate = false`.
|
||||
- Magnet and other fields are in plaintext.
|
||||
- Visible to all users.
|
||||
|
||||
### Private Posts
|
||||
- `isPrivate = true`.
|
||||
- Main magnet link (and optional fields like `description` or `thumbnail`) are encrypted with the preferred encryption method.
|
||||
- `extraMagnets` may also be encrypted if the user chooses.
|
||||
|
||||
---
|
||||
|
||||
## New or Expanded Features in Version 3
|
||||
|
||||
1. **Adult Content Flag**
|
||||
- `adult: true` marks content as adult-only.
|
||||
- Clients can filter this by default.
|
||||
|
||||
2. **Multiple Categories**
|
||||
- `categories` can hold several entries, e.g. `["comedy", "music"]`.
|
||||
- Advanced filtering is possible.
|
||||
|
||||
3. **Extended Metadata**
|
||||
- Fields like `language`, `payment`, and custom data let creators provide more details.
|
||||
- Optional, so older clients ignore what they do not recognize.
|
||||
|
||||
4. **Multi-Resolution or Multi-Format Links**
|
||||
- `extraMagnets` for video variants.
|
||||
- `audioQuality` for different audio formats or bitrates.
|
||||
|
||||
5. **Internationalization (`i18n`)**
|
||||
- Include translated or localized titles, descriptions, and more.
|
||||
|
||||
6. **Encrypted Metadata**
|
||||
- `encryptedMeta: true` to signal that certain fields (e.g., `description`, `thumbnail`) may be encrypted.
|
||||
|
||||
7. **Audio, Podcast, Audiobook Support**
|
||||
- New optional fields: `contentType`, `albumName`, `trackNumber`, `podcastSeries`, etc.
|
||||
- `playlist` can reference multiple tracks under one post.
|
||||
|
||||
---
|
||||
|
||||
Below is a section you can drop into your Version 3 specification. It expands on filtering logic for adult content and outlines a basic submission flow to help implementers. Feel free to adjust headings or formatting as needed.
|
||||
|
||||
---
|
||||
|
||||
## Filtering Logic and Submission Flow
|
||||
|
||||
### Filtering Logic
|
||||
|
||||
1. **Adult Content Detection**
|
||||
- To mark posts as adult-only, set `adult = true` inside the content object (e.g., `"adult": true`) or include `["adult", "true"]` in the `tags` array.
|
||||
- Clients should exclude these posts by default unless users explicitly enable adult content in their settings or preferences.
|
||||
|
||||
2. **Category-Based Filtering**
|
||||
- If `categories` is present (e.g., `["comedy", "music"]`), clients can allow users to search or filter based on these entries.
|
||||
- Alternatively, if tags such as `["t", "video"]` or `["category", "comedy"]` are used, handle them in the same way by grouping or filtering.
|
||||
|
||||
3. **Default UI Behavior**
|
||||
- Provide a toggle or checkbox for “Show Adult Content.” If unchecked, do not display posts marked as adult content.
|
||||
- Offer a dropdown or checkbox list for category filters. Only display posts that match selected categories.
|
||||
|
||||
4. **Edge Cases**
|
||||
- If both `adult = true` and one or more categories are set, the post should remain hidden unless the user opts into adult content, even if it matches other selected categories.
|
||||
- When a post omits `adult`, assume it is not adult content unless the user or platform policy states otherwise.
|
||||
|
||||
### Submission Flow Example
|
||||
|
||||
Below is a simple illustration of how a client could guide users when creating or editing a post:
|
||||
|
||||
1. **User Fills Out the Form**
|
||||
- Title, magnet link, description, and any other relevant fields.
|
||||
|
||||
2. **Set the Version**
|
||||
- When adding new Version 3 features (e.g., multiple categories, adult flag), ensure `version` is set to `3`.
|
||||
- If editing a Version 2 post to add adult or category data, update `version` to `3`.
|
||||
|
||||
3. **Adult Content Checkbox**
|
||||
- If the user marks the post as adult-only, set `"adult": true` in `content` or include `["adult", "true"]` in `tags`.
|
||||
- If not marked, leave the field out or set it to `false`.
|
||||
|
||||
4. **Category Selection**
|
||||
- Let users select categories (e.g., “comedy,” “music,” “gaming”). Store them in `categories` or as tags (`["category", "comedy"]`).
|
||||
|
||||
5. **Publish**
|
||||
- Create the Nostr event with `kind = 30078`.
|
||||
- In the `content` field (as JSON), include the user-provided data plus the new fields (e.g., `adult`, `categories`).
|
||||
- In the `tags` array, ensure `["t", "video"]` or any other relevant tags. Add `["adult", "true"]` if needed.
|
||||
|
||||
6. **Post-Submission**
|
||||
- When the post is published, clients or relays can immediately filter or categorize it according to the adult flag and categories.
|
||||
- Users who opted in to adult content see the post, while others do not.
|
||||
|
||||
This process helps developers maintain a consistent approach to adult content handling, category-based filtering, and integration of new Version 3 features.
|
||||
|
||||
---
|
||||
|
||||
## Editing and Deleting Posts
|
||||
|
||||
### Editing
|
||||
- Retain the same `["d", "<unique-id>"]` tag.
|
||||
- Update or add new fields (e.g., adding `extraMagnets` or switching `contentType` to `"music"`).
|
||||
- When moving from `version=2` to `version=3`, older clients ignore new fields but still see basic fields like `title` and `magnet`.
|
||||
|
||||
### Deletion
|
||||
- Mark `deleted: true` in the `content`.
|
||||
- Remove or encrypt sensitive fields (e.g., `magnet`, `description`) so they are not visible to others.
|
||||
|
||||
---
|
||||
|
||||
## Example Post: Version 3 (Video)
|
||||
## **5. Example Post: Version 3 (Video)**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -196,10 +93,10 @@ This process helps developers maintain a consistent approach to adult content ha
|
||||
"created_at": 1700000000,
|
||||
"tags": [
|
||||
["t", "video"],
|
||||
["d", "unique-identifier"]
|
||||
// ["adult", "true"] // optional if storing adult info in tags
|
||||
["d", "my-unique-handle"]
|
||||
],
|
||||
"content": "{
|
||||
\"videoRootId\": \"root-1678551042-abc123\",
|
||||
\"version\": 3,
|
||||
\"deleted\": false,
|
||||
\"isPrivate\": false,
|
||||
@@ -224,20 +121,24 @@ This process helps developers maintain a consistent approach to adult content ha
|
||||
}
|
||||
```
|
||||
|
||||
- **videoRootId**: `root-1678551042-abc123` ensures future edits or deletes reference the same item.
|
||||
- If `deleted = true`, the client sees it as a soft delete overshadowing the original.
|
||||
|
||||
---
|
||||
|
||||
## Example Post: Version 3 (Music Track)
|
||||
## **6. Example Post: Version 3 (Audio)**
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "npub1...",
|
||||
"created_at": 1700000000,
|
||||
"created_at": 1700000001,
|
||||
"tags": [
|
||||
["t", "music"],
|
||||
["d", "unique-id-for-track"]
|
||||
["d", "my-song-handle"]
|
||||
],
|
||||
"content": "{
|
||||
\"videoRootId\": \"root-1678551042-xyz999\",
|
||||
\"version\": 3,
|
||||
\"deleted\": false,
|
||||
\"isPrivate\": false,
|
||||
@@ -259,7 +160,7 @@ This process helps developers maintain a consistent approach to adult content ha
|
||||
\"magnet\": \"magnet:?xt=urn:btih:mp3Hash\"
|
||||
}
|
||||
],
|
||||
\"description\": \"This is an amazing track from the Great Album.\",
|
||||
\"description\": \"A track from the Great Album.\",
|
||||
\"categories\": [\"music\", \"pop\"],
|
||||
\"contributors\": [
|
||||
{ \"name\": \"John Doe\", \"role\": \"Producer\" },
|
||||
@@ -269,62 +170,127 @@ This process helps developers maintain a consistent approach to adult content ha
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example Post: Version 3 (Playlist or Album)
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "npub1...",
|
||||
"created_at": 1700000000,
|
||||
"tags": [
|
||||
["t", "playlist"],
|
||||
["d", "unique-id-for-playlist"]
|
||||
],
|
||||
"content": "{
|
||||
\"version\": 3,
|
||||
\"deleted\": false,
|
||||
\"isPrivate\": false,
|
||||
\"title\": \"Chill Vibes Playlist\",
|
||||
\"contentType\": \"music\",
|
||||
\"playlist\": [
|
||||
\"magnet:?xt=urn:btih:hashTrack1\",
|
||||
\"magnet:?xt=urn:btih:hashTrack2\",
|
||||
\"magnet:?xt=urn:btih:hashTrack3\"
|
||||
],
|
||||
\"description\": \"Curated set of relaxing tracks.\",
|
||||
\"categories\": [\"music\", \"chill\"]
|
||||
}"
|
||||
}
|
||||
```
|
||||
- **videoRootId**: `root-1678551042-xyz999` used to link edits/deletes.
|
||||
|
||||
---
|
||||
|
||||
## Transition Plan from Version 2 to 3
|
||||
## **7. Tagging**
|
||||
|
||||
1. **Backward Compatibility**
|
||||
- All new fields are optional.
|
||||
- Version 2 clients still see basic fields like `title` and `magnet`.
|
||||
|
||||
2. **Gradual Roll-Out**
|
||||
- Existing posts remain at `version=2`.
|
||||
- Users can adopt `version=3` when editing or creating new posts with extra features.
|
||||
|
||||
3. **Client Handling**
|
||||
- If a client detects `version=3`, it can display or parse additional fields.
|
||||
- Otherwise, it treats posts with older logic.
|
||||
|
||||
4. **Relay Compatibility**
|
||||
- The same `kind=30078` is used.
|
||||
- Relays generally store the data as-is.
|
||||
|
||||
5. **Potential Breaking Changes**
|
||||
- If certain new fields are mandatory in your app, older clients may not parse them.
|
||||
- Keep new features optional where possible.
|
||||
- `["t", "video"]` or `["t", "music"]` for quick type references.
|
||||
- `["d", "<unique-handle>"]` for a stable “address” pointer.
|
||||
- You can store adult flags or categories in tags, e.g. `["adult", "true"]` or `["category", "comedy"]`.
|
||||
- However, storing them inside `content` (e.g. `adult=true` or `categories=["comedy"]`) is generally recommended so older clients can ignore them gracefully.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
## **8. Handling Edits and Deletes**
|
||||
|
||||
Version 3 extends the specification to include adult flags, multi-category tagging, multi-resolution magnet links, internationalization, and now audio-specific fields like `contentType = "music"`, `podcastSeries`, and `playlist`. Older clients can keep working without error, while new clients can take advantage of these extra fields for better organization and search.
|
||||
### **8.1 Edits**
|
||||
|
||||
1. **videoRootId**: Keep the same `videoRootId` to overshadow the previous version.
|
||||
2. **version**: Bump from `2` → `3` or `3` → a higher sub-version if you wish to track changes.
|
||||
3. **tags**: Typically reuse `["d", "<unique-handle>"]` or create a new d-tag. The important part is to keep `videoRootId` consistent.
|
||||
|
||||
### **8.2 Deletion**
|
||||
|
||||
1. **deleted = true**: Mark the item as deleted in the `content` JSON.
|
||||
2. **Remove or encrypt** sensitive fields (`magnet`, `description`, etc.).
|
||||
3. **videoRootId**: Must remain the same as the original so clients remove/overshadow the old item.
|
||||
4. **subscribeVideos** Logic:
|
||||
```js
|
||||
if (video.deleted) {
|
||||
// remove from activeMap or overshadow the old entry
|
||||
}
|
||||
```
|
||||
5. **fetchVideos** Logic:
|
||||
```js
|
||||
for (const [id, video] of allEvents.entries()) {
|
||||
if (video.deleted) continue; // skip deleted
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## **9. Filtering Logic**
|
||||
|
||||
1. **Adult Content**: If `adult = true`, clients typically hide the post unless the user enables adult content in settings.
|
||||
2. **Categories**: Provide optional grouping or searching by `categories`.
|
||||
3. **Language**: If specified, clients can filter by language.
|
||||
4. **Encryption**: If `isPrivate = true`, some fields (e.g., `magnet`) may need client-side decryption.
|
||||
|
||||
---
|
||||
|
||||
## **10. Submission Flow**
|
||||
|
||||
1. **Client Form**: Title, magnet link, optional category checkboxes, adult toggle, etc.
|
||||
2. **videoRootId**: For new posts, generate a new root ID. For edits, reuse the old post’s root ID.
|
||||
3. **Publish**:
|
||||
- `kind = 30078`
|
||||
- `content` → JSON with `videoRootId`, `version=3`, `title`, `magnet`, etc.
|
||||
- `tags` → e.g., `["t","video"]`, `["d","my-handle"]`.
|
||||
4. **Visibility**: If `adult=true`, hide from minors or default searches. If `deleted=true`, overshadow old entry.
|
||||
|
||||
---
|
||||
|
||||
## **11. Example “Delete” Flow**
|
||||
|
||||
1. **Original Post** (not deleted):
|
||||
```json
|
||||
{
|
||||
"content": "{
|
||||
\"videoRootId\": \"root-1234\",
|
||||
\"version\": 3,
|
||||
\"deleted\": false,
|
||||
\"title\": \"My Video\",
|
||||
\"magnet\": \"magnet:?xt=hash\"
|
||||
// ...
|
||||
}"
|
||||
}
|
||||
```
|
||||
2. **Delete Post** (new event):
|
||||
```jsonc
|
||||
{
|
||||
"kind": 30078,
|
||||
"pubkey": "...",
|
||||
"tags": [
|
||||
["t","video"],
|
||||
["d","my-handle"] // same d-tag
|
||||
],
|
||||
"content": "{
|
||||
\"videoRootId\": \"root-1234\", // same root as original
|
||||
\"version\": 3,
|
||||
\"deleted\": true,
|
||||
\"title\": \"My Video\",
|
||||
\"magnet\": \"\", // blank or removed
|
||||
\"description\": \"Video was deleted by creator.\"
|
||||
}"
|
||||
}
|
||||
```
|
||||
3. **subscribeVideos** sees `deleted=true` and overshadow logic removes the old item from active view.
|
||||
|
||||
---
|
||||
|
||||
## **12. Backward Compatibility**
|
||||
|
||||
- **Version 2** fields remain recognized: `title`, `magnet`, `mode`, etc.
|
||||
- **Older Clients**: Ignore fields like `videoRootId`, `categories`, `adult`, etc.
|
||||
- **When Upgrading**: If you add `videoRootId` or adult flags to an older post, older clients still display basic info but won’t filter on the new fields.
|
||||
|
||||
---
|
||||
|
||||
## **13. Summary**
|
||||
|
||||
**Version 3** is a superset of previous versions, adding:
|
||||
|
||||
1. **videoRootId** for overshadow logic and grouping multi-edit threads.
|
||||
2. **Adult content flag**, multi-category, i18n, and other optional fields.
|
||||
3. **Audio/podcast support**: fields like `contentType = "music"`, `podcastSeries`, `episodeNumber`, and `playlist`.
|
||||
|
||||
Clients that implement these features can sort, filter, or display richer media experiences while remaining compatible with older Nostr note readers.
|
||||
|
||||
---
|
||||
|
||||
**End of Document**
|
||||
|
||||
This expanded spec ensures that **videoRootId** is used consistently, clarifies how to handle adult content, and extends the data model for new media types. By following this Version 3 guidance, you’ll maintain backward compatibility while enabling advanced features for media sharing on Nostr.
|
||||
|
@@ -1,7 +1,7 @@
|
||||
:root {
|
||||
--color-bg: #0f172a;
|
||||
--color-card: #1e293b;
|
||||
--color-primary: #f43f5e;
|
||||
--color-primary: #fe0032;
|
||||
--color-secondary: #ff93a5;
|
||||
--color-text: #f8fafc;
|
||||
--color-muted: #94a3b8;
|
||||
@@ -229,7 +229,11 @@ textarea:focus {
|
||||
ring: 2px var(--color-primary);
|
||||
}
|
||||
|
||||
/* Global button styles -- only apply to normal (non-icon) buttons */
|
||||
/* -------------------------------------------
|
||||
COMMENTED OUT the 'button:not(.icon-button)'
|
||||
global rule that overrides your circles
|
||||
--------------------------------------------
|
||||
|
||||
button:not(.icon-button) {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: var(--color-primary);
|
||||
@@ -248,6 +252,7 @@ button:not(.icon-button):focus {
|
||||
outline: none;
|
||||
ring: 2px var(--color-primary);
|
||||
}
|
||||
*/
|
||||
|
||||
/* Utility Classes */
|
||||
.line-clamp-2 {
|
||||
@@ -262,25 +267,19 @@ button:not(.icon-button):focus {
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
/* Base styling without a forced display */
|
||||
#errorContainer,
|
||||
#successContainer {
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center; /* Keep the rest of your styling */
|
||||
}
|
||||
|
||||
/* When not hidden, display as flex */
|
||||
#errorContainer:not(.hidden),
|
||||
#successContainer:not(.hidden) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#errorContainer {
|
||||
background-color: rgb(220 38 38 / 0.1);
|
||||
color: #fecaca;
|
||||
border: 1px solid rgb(220 38 38 / 0.2);
|
||||
}
|
||||
|
||||
#successContainer {
|
||||
background-color: rgb(34 197 94 / 0.1);
|
||||
color: #bbf7d0;
|
||||
border: 1px solid rgb(34 197 94 / 0.2);
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@@ -425,18 +424,13 @@ footer a:hover {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* --- New Classes for Icon Buttons & Images --- */
|
||||
|
||||
/* Circular icon buttons */
|
||||
/* Circular Icon Buttons */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Fixed width/height for a perfect circle */
|
||||
width: 2.5rem; /* 40px */
|
||||
height: 2.5rem; /* 40px */
|
||||
|
||||
line-height: 0;
|
||||
background-color: #3f3f46; /* Gray 700 */
|
||||
color: #fff;
|
||||
@@ -446,40 +440,28 @@ footer a:hover {
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* Hover state: slightly lighter gray */
|
||||
.icon-button:hover {
|
||||
background-color: #52525b; /* Gray 600 */
|
||||
}
|
||||
|
||||
/* Focus/active states: red ring */
|
||||
.icon-button:focus,
|
||||
.icon-button:active {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6); /* Red ring #dc2626 */
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6);
|
||||
}
|
||||
|
||||
/* Icon images (force white if originally black) */
|
||||
.icon-image {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
|
||||
/*
|
||||
If your icon is black and you want to invert it to white, use this:
|
||||
filter: brightness(0) invert(1);
|
||||
|
||||
If your icon is already white, keep it commented out or remove it.
|
||||
*/
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Create a container that preserves a 16:9 aspect ratio via padding-top. */
|
||||
/* Force 16:9 ratio using padding-top technique */
|
||||
/* Ratio 16:9 Container */
|
||||
.ratio-16-9 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* 16:9 => 9/16 = 0.5625 => 56.25% */
|
||||
background-color: #1e293b; /* fallback background if image doesn't load */
|
||||
padding-top: 56.25%;
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.ratio-16-9 > img {
|
||||
|
1
src/css/tailwind.min.css
vendored
Normal file
1
src/css/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
284
src/index.html
284
src/index.html
@@ -1,60 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Decentralized Video Sharing</title>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Decentralized Video Sharing</title>
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content="BitVid - Decentralized Video Sharing"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Share videos and follow creators freely, in a truly decentralized way."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://bitvid.netlify.app/assets/jpg/bitvid.jpg"
|
||||
/>
|
||||
<meta property="og:url" content="https://bitvid.network" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Share videos and follow creators freely, in a truly decentralized way."
|
||||
/>
|
||||
<meta property="og:image" content="assets/jpg/bitvid.jpg" />
|
||||
<meta property="og:url" content="https://bitvid.network" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="assets/png/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="assets/png/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="assets/png/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="assets/png/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- Styles -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Rest of your page content -->
|
||||
</body>
|
||||
</html>
|
||||
<!-- Styles -->
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100">
|
||||
<div
|
||||
@@ -62,43 +45,56 @@
|
||||
class="container mx-auto px-4 py-8 min-h-screen flex flex-col"
|
||||
>
|
||||
<!-- Header -->
|
||||
<header class="mb-8">
|
||||
<div class="flex items-start">
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<header class="mb-8 flex items-center">
|
||||
<!-- Logo on the left -->
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||
alt="BitVid Logo"
|
||||
class="h-16"
|
||||
/>
|
||||
|
||||
<!-- Login Section -->
|
||||
<div id="loginSection" class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<!-- Buttons on the far right -->
|
||||
<div class="ml-auto flex items-center space-x-4">
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
id="loginButton"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
style="background-color: #fe0032"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full text-white text-sm font-bold leading-none whitespace-nowrap appearance-none hover:bg-[#e6002c] focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2"
|
||||
>
|
||||
Login with Nostr
|
||||
NIP-07
|
||||
</button>
|
||||
<p id="userStatus" class="mt-4 text-gray-500 hidden">
|
||||
Logged in as: <span id="userPubKey"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- Upload (Add Video) Button -->
|
||||
<button
|
||||
id="logoutButton"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 hidden"
|
||||
id="uploadButton"
|
||||
style="background-color: #fe0032"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full text-white text-xl font-bold leading-none whitespace-nowrap appearance-none hover:bg-[#e6002c] focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2 hidden"
|
||||
>
|
||||
Logout
|
||||
+
|
||||
</button>
|
||||
|
||||
<!-- Profile Button -->
|
||||
<button
|
||||
id="profileButton"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-black text-white text-sm leading-none hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-black hidden"
|
||||
>
|
||||
<!-- Ensures a border around the smaller avatar -->
|
||||
<div class="w-10 h-10 rounded-full overflow-hidden">
|
||||
<img
|
||||
id="profileAvatar"
|
||||
src="assets/jpg/default-profile.jpg"
|
||||
alt="Profile"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Error Container -->
|
||||
<div
|
||||
id="errorContainer"
|
||||
class="hidden bg-red-100 text-red-800 p-4 rounded-md mb-4"
|
||||
class="hidden bg-red-100 text-red-900 p-4 rounded-md mb-4"
|
||||
>
|
||||
<!-- Error messages will appear here -->
|
||||
</div>
|
||||
@@ -106,127 +102,20 @@
|
||||
<!-- Success Container -->
|
||||
<div
|
||||
id="successContainer"
|
||||
class="hidden bg-green-100 text-green-800 p-4 rounded-md mb-4"
|
||||
class="hidden bg-green-100 text-green-900 p-4 rounded-md mb-4"
|
||||
>
|
||||
<!-- Success messages will appear here -->
|
||||
</div>
|
||||
|
||||
<!-- Video Submission Form -->
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-md mb-8 hidden"
|
||||
id="videoFormContainer"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">Share a Video</h2>
|
||||
<form id="submitForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<!-- Main container for dynamic views -->
|
||||
<main id="viewContainer" class="flex-grow mb-8">
|
||||
<!-- We'll load "most-recent-videos.html" or other views here -->
|
||||
</main>
|
||||
|
||||
<div>
|
||||
<label for="magnet" class="block text-sm font-medium text-gray-700"
|
||||
>Magnet Link</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="magnet"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="thumbnail"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Thumbnail URL (optional)</label
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
id="thumbnail"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="3"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- ADDED FOR PRIVATE LISTINGS -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPrivate"
|
||||
class="form-checkbox h-5 w-5"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700"
|
||||
>Private Listing (Encrypt Magnet)</span
|
||||
>
|
||||
</div>
|
||||
<!-- END ADDED FOR PRIVATE LISTINGS -->
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Share Video
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Video Player Section -->
|
||||
<div id="playerSection" class="mb-8 hidden">
|
||||
<video id="video" controls class="w-full rounded-lg shadow-md"></video>
|
||||
<!-- Status and Stats -->
|
||||
<div class="mt-4">
|
||||
<div id="status" class="text-gray-700 mb-2">
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 rounded-full h-2 mb-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full"
|
||||
id="progress"
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<span id="peers">Peers: 0</span>
|
||||
<span id="speed">0 KB/s</span>
|
||||
<span id="downloaded">0 MB / 0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video List -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
id="videoList"
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||
>
|
||||
<!-- Videos will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imported Video Player Modal -->
|
||||
<!-- Imported Video Player Modal (goes into modalContainer) -->
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- Tagline / Slogan -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
||||
seed. zap. subscribe.
|
||||
@@ -237,7 +126,6 @@
|
||||
<div id="disclaimerModal" class="hidden">
|
||||
<div class="modal-content">
|
||||
<div class="modal-scroll">
|
||||
<!-- Logo -->
|
||||
<div class="flex justify-center mb-8">
|
||||
<img
|
||||
src="assets/svg/bitvid-logo-dark-mode.svg"
|
||||
@@ -276,7 +164,6 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="space-y-6 text-gray-300">
|
||||
<p>
|
||||
bitvid is a decentralized video platform where content is shared
|
||||
@@ -330,7 +217,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Button in fixed container -->
|
||||
<div class="button-container">
|
||||
<button
|
||||
id="acceptDisclaimer"
|
||||
@@ -440,17 +326,17 @@
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<!-- Load WebTorrent via CDN -->
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/webtorrent/1.9.7/webtorrent.min.js"></script> -->
|
||||
<!-- Load Nostr library -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<!-- Load JavaScript Modules -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<script type="module" src="js/config.js"></script>
|
||||
<script type="module" src="js/lists.js"></script>
|
||||
<script type="module" src="js/accessControl.js"></script>
|
||||
<script type="module" src="js/webtorrent.js"></script>
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
|
||||
<!-- Optional: a separate manager for view loading -->
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
|
||||
<!-- Main app script -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
|
1238
src/js/app.js
1238
src/js/app.js
File diff suppressed because it is too large
Load Diff
558
src/js/nostr.js
558
src/js/nostr.js
@@ -11,10 +11,9 @@ const RELAY_URLS = [
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Rate limiting for error logs
|
||||
// Just a helper to keep error spam in check
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
||||
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
function logErrorOnce(message, eventContent = null) {
|
||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
||||
console.error(message);
|
||||
@@ -31,8 +30,8 @@ function logErrorOnce(message, eventContent = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A very naive "encryption" function that just reverses the string.
|
||||
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
||||
* Example "encryption" that just reverses strings.
|
||||
* In real usage, swap with actual crypto.
|
||||
*/
|
||||
function fakeEncrypt(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
@@ -41,18 +40,63 @@ function fakeDecrypt(encrypted) {
|
||||
return encrypted.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Nostr event => your "video" object.
|
||||
*/
|
||||
function convertEventToVideo(event) {
|
||||
const content = JSON.parse(event.content || "{}");
|
||||
return {
|
||||
id: event.id,
|
||||
// We store a 'videoRootId' in content so we can group multiple edits
|
||||
videoRootId: content.videoRootId || null,
|
||||
version: content.version ?? 1,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key each "active" video by its root ID => so you only store
|
||||
* the newest version for each root. But for older events w/o videoRootId,
|
||||
* or w/o 'd' tag, we handle fallback logic below.
|
||||
*/
|
||||
function getActiveKey(video) {
|
||||
// If it has a videoRootId, we use that
|
||||
if (video.videoRootId) {
|
||||
return `ROOT:${video.videoRootId}`;
|
||||
}
|
||||
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
|
||||
// This is a fallback approach so older events appear in the "active map".
|
||||
const dTag = video.tags?.find((t) => t[0] === "d");
|
||||
if (dTag) {
|
||||
return `${video.pubkey}:${dTag[1]}`;
|
||||
}
|
||||
return `LEGACY:${video.id}`;
|
||||
}
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.pubkey = null;
|
||||
this.relays = RELAY_URLS;
|
||||
|
||||
// We keep a Map of subscribed videos for quick lookups by event.id
|
||||
this.subscribedVideos = new Map();
|
||||
// All events—old or new—so older share links still work
|
||||
this.allEvents = new Map();
|
||||
|
||||
// "activeMap" holds only the newest version for each root ID (or fallback).
|
||||
this.activeMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Nostr client by connecting to relays.
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
async init() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
@@ -63,18 +107,16 @@ class NostrClient {
|
||||
const successfulRelays = results
|
||||
.filter((r) => r.success)
|
||||
.map((r) => r.url);
|
||||
|
||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
||||
|
||||
if (isDevMode)
|
||||
if (isDevMode) {
|
||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Nostr init failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to handle relay connections
|
||||
async connectToRelays() {
|
||||
return Promise.all(
|
||||
this.relays.map(
|
||||
@@ -100,49 +142,40 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user using a Nostr extension or by entering an NSEC key.
|
||||
* Attempt Nostr extension login or abort
|
||||
*/
|
||||
async login() {
|
||||
try {
|
||||
if (!window.nostr) {
|
||||
console.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (like Alby or nos2x)."
|
||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||
);
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
|
||||
// Debug logs
|
||||
if (isDevMode) {
|
||||
console.log("Got pubkey:", pubkey);
|
||||
console.log("Converted to npub:", npub);
|
||||
console.log("Whitelist:", accessControl.getWhitelist());
|
||||
console.log("Blacklist:", accessControl.getBlacklist());
|
||||
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
|
||||
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
|
||||
}
|
||||
|
||||
// Check access control
|
||||
// Access control check
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error(
|
||||
"Your account has been blocked from accessing this platform."
|
||||
);
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Access is currently restricted to whitelisted users only."
|
||||
);
|
||||
throw new Error("Access restricted to whitelisted users only.");
|
||||
}
|
||||
}
|
||||
|
||||
this.pubkey = pubkey;
|
||||
if (isDevMode)
|
||||
console.log(
|
||||
"Successfully logged in with extension. Public key:",
|
||||
this.pubkey
|
||||
);
|
||||
if (isDevMode) {
|
||||
console.log("Logged in with extension. Pubkey:", this.pubkey);
|
||||
}
|
||||
return this.pubkey;
|
||||
} catch (e) {
|
||||
console.error("Login error:", e);
|
||||
@@ -150,17 +183,11 @@ class NostrClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the user.
|
||||
*/
|
||||
logout() {
|
||||
this.pubkey = null;
|
||||
if (isDevMode) console.log("User logged out.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an NSEC key.
|
||||
*/
|
||||
decodeNsec(nsec) {
|
||||
try {
|
||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
||||
@@ -171,271 +198,253 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new video event to all relays (creates a brand-new note).
|
||||
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
|
||||
*/
|
||||
async publishVideo(videoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Publishing video with data:", videoData);
|
||||
console.log("Publishing new video with data:", videoData);
|
||||
}
|
||||
|
||||
// If user sets "isPrivate = true", encrypt the magnet
|
||||
let finalMagnet = videoData.magnet;
|
||||
if (videoData.isPrivate === true) {
|
||||
if (videoData.isPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalMagnet);
|
||||
}
|
||||
|
||||
// Default version is 1 if not specified
|
||||
const version = videoData.version ?? 1;
|
||||
// new "videoRootId" ensures all future edits know they're from the same root
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const uniqueD = `${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 10)}`;
|
||||
|
||||
// Always mark "deleted" false for new posts
|
||||
const contentObject = {
|
||||
version,
|
||||
videoRootId,
|
||||
version: videoData.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: videoData.isPrivate || false,
|
||||
title: videoData.title,
|
||||
isPrivate: videoData.isPrivate ?? false,
|
||||
title: videoData.title || "",
|
||||
magnet: finalMagnet,
|
||||
thumbnail: videoData.thumbnail,
|
||||
description: videoData.description,
|
||||
mode: videoData.mode,
|
||||
thumbnail: videoData.thumbnail || "",
|
||||
description: videoData.description || "",
|
||||
mode: videoData.mode || "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 100),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", uniqueD],
|
||||
["d", dTagValue],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Event content after stringify:", event.content);
|
||||
console.log("Using d tag:", uniqueD);
|
||||
console.log("Publish event with brand-new root:", videoRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed event:", signedEvent);
|
||||
}
|
||||
if (isDevMode) console.log("Signed event:", signedEvent);
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`Event published to ${url}`);
|
||||
}
|
||||
if (isDevMode) console.log(`Video published to ${url}`);
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Failed to publish to ${url}:`, err.message);
|
||||
}
|
||||
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign event.");
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing video event by reusing the same "d" tag.
|
||||
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
||||
* Edits a video by creating a *new event* with a brand-new d tag,
|
||||
* but reuses the same videoRootId as the original.
|
||||
* => old link remains pinned to the old event, new link is a fresh ID.
|
||||
*/
|
||||
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
async editVideo(originalVideo, updatedData, pubkey) {
|
||||
if (!pubkey) throw new Error("Not logged in to edit.");
|
||||
if (originalVideo.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this video (different pubkey).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Editing video event:", originalEvent);
|
||||
console.log("New video data:", updatedVideoData);
|
||||
}
|
||||
// Use the videoRootId directly from the converted video
|
||||
const rootId = originalVideo.videoRootId || null;
|
||||
|
||||
// Grab the d tag from the original event
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error(
|
||||
'This event has no "d" tag, cannot edit as addressable kind=30078.'
|
||||
);
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
// Parse old content
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
if (isDevMode) {
|
||||
console.log("Old content:", oldContent);
|
||||
}
|
||||
|
||||
// Keep old version & deleted status
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
const oldDeleted = oldContent.deleted === true;
|
||||
const newVersion = updatedVideoData.version ?? oldVersion;
|
||||
|
||||
const oldWasPrivate = oldContent.isPrivate === true;
|
||||
|
||||
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
|
||||
let oldPlainMagnet = oldContent.magnet || "";
|
||||
if (oldWasPrivate && oldPlainMagnet) {
|
||||
// Decrypt the old magnet if it was private
|
||||
let oldPlainMagnet = originalVideo.magnet || "";
|
||||
if (originalVideo.isPrivate && oldPlainMagnet) {
|
||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||
}
|
||||
|
||||
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
||||
const newIsPrivate =
|
||||
typeof updatedVideoData.isPrivate === "boolean"
|
||||
? updatedVideoData.isPrivate
|
||||
: oldContent.isPrivate ?? false;
|
||||
// Determine new privacy setting
|
||||
const wantPrivate =
|
||||
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
|
||||
|
||||
// 3) The user might type a new magnet or keep oldPlainMagnet
|
||||
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
||||
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
||||
// Fallback to old magnet if none provided
|
||||
let finalPlainMagnet = (updatedData.magnet || "").trim();
|
||||
if (!finalPlainMagnet) {
|
||||
finalPlainMagnet = oldPlainMagnet;
|
||||
}
|
||||
|
||||
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
||||
// Re-encrypt if user wants private
|
||||
let finalMagnet = finalPlainMagnet;
|
||||
if (newIsPrivate) {
|
||||
if (wantPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
||||
}
|
||||
|
||||
// If there's no root yet (legacy), generate it
|
||||
const newRootId =
|
||||
rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Build updated content
|
||||
const contentObject = {
|
||||
version: newVersion,
|
||||
deleted: oldDeleted,
|
||||
isPrivate: newIsPrivate,
|
||||
title: updatedVideoData.title,
|
||||
videoRootId: newRootId,
|
||||
version: updatedData.version ?? originalVideo.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: wantPrivate,
|
||||
title: updatedData.title ?? originalVideo.title,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: updatedVideoData.thumbnail,
|
||||
description: updatedVideoData.description,
|
||||
mode: updatedVideoData.mode,
|
||||
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
|
||||
description: updatedData.description ?? originalVideo.description,
|
||||
mode: updatedData.mode ?? originalVideo.mode ?? "live",
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Building updated content object:", contentObject);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", existingD],
|
||||
["d", newD], // new share link
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag:", existingD);
|
||||
console.log("Updated event content:", event.content);
|
||||
console.log("Creating edited event with root ID:", newRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed edited event:", signedEvent);
|
||||
}
|
||||
|
||||
// Publish to all relays
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Edited event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(
|
||||
`Failed to publish edited event to ${url}:`,
|
||||
err.message
|
||||
);
|
||||
console.error(`Publish failed to ${url}`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign edited event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign edited event.");
|
||||
} catch (err) {
|
||||
console.error("Edit failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
||||
* and republishing with the same (kind=30078, pubkey, d) address.
|
||||
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
|
||||
*/
|
||||
async deleteVideo(originalEvent, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
throw new Error("Not logged in to delete.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
throw new Error("Not your event (pubkey mismatch).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Deleting video event:", originalEvent);
|
||||
// If front-end didn't pass the tags array, load the full event from local or from the relay:
|
||||
let baseEvent = originalEvent;
|
||||
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
|
||||
const fetched = await this.getEventById(originalEvent.id);
|
||||
if (!fetched) {
|
||||
throw new Error("Could not fetch the original event for deletion.");
|
||||
}
|
||||
// Rebuild baseEvent as a raw Nostr event that includes .tags and .content
|
||||
baseEvent = {
|
||||
id: fetched.id,
|
||||
pubkey: fetched.pubkey,
|
||||
// put the raw JSON content back into string form:
|
||||
content: JSON.stringify({
|
||||
version: fetched.version,
|
||||
deleted: fetched.deleted,
|
||||
isPrivate: fetched.isPrivate,
|
||||
title: fetched.title,
|
||||
magnet: fetched.magnet,
|
||||
thumbnail: fetched.thumbnail,
|
||||
description: fetched.description,
|
||||
mode: fetched.mode,
|
||||
}),
|
||||
tags: fetched.tags,
|
||||
};
|
||||
}
|
||||
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
// Now try to get the old d-tag
|
||||
const dTag = baseEvent.tags.find((t) => t[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error(
|
||||
'This event has no "d" tag, cannot delete as addressable kind=30078.'
|
||||
);
|
||||
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
// After you've parsed oldContent:
|
||||
const oldContent = JSON.parse(baseEvent.content || "{}");
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
// Mark it "deleted" and clear out magnet, thumbnail, etc.
|
||||
// ADD this block to handle the old root or fallback:
|
||||
let finalRootId = oldContent.videoRootId || null;
|
||||
if (!finalRootId) {
|
||||
// If it’s a legacy video (no root), we can fallback to your
|
||||
// existing logic used by getActiveKey. For instance, if it had a 'd' tag:
|
||||
if (dTag) {
|
||||
// Some devs store it as 'LEGACY:pubkey:dTagValue'
|
||||
// or you could just store the same as the old approach:
|
||||
finalRootId = `LEGACY:${baseEvent.pubkey}:${dTag[1]}`;
|
||||
} else {
|
||||
finalRootId = `LEGACY:${baseEvent.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Now build the content object, including videoRootId:
|
||||
const contentObject = {
|
||||
videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
title: oldContent.title || "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This video has been deleted.",
|
||||
description: "Video was deleted by creator.",
|
||||
mode: oldContent.mode || "live",
|
||||
isPrivate: oldContent.isPrivate || false,
|
||||
};
|
||||
|
||||
// Reuse the same d-tag for an addressable edit
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
// We reuse the same d => overshadow the original event
|
||||
["d", existingD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag for delete:", existingD);
|
||||
console.log("Deleted event content:", event.content);
|
||||
console.log("Deleting video => mark 'deleted:true'.", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -444,14 +453,13 @@ class NostrClient {
|
||||
console.log("Signed deleted event:", signedEvent);
|
||||
}
|
||||
|
||||
// Publish everywhere
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Deleted event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
console.log(`Delete event published to ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
@@ -460,80 +468,67 @@ class NostrClient {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign deleted event:", error);
|
||||
console.error("Failed to sign deleted event:", err);
|
||||
}
|
||||
throw new Error("Failed to sign deleted event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to video events from all configured relays, storing them in a Map.
|
||||
*
|
||||
* @param {Function} onVideo - Callback fired for each new/updated video
|
||||
* Subscribes to *all* video events. We store them in this.allEvents so older
|
||||
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
|
||||
* version of each root (or fallback).
|
||||
*/
|
||||
subscribeVideos(onVideo) {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 500, // Adjust as needed
|
||||
limit: 500,
|
||||
since: 0,
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||
}
|
||||
|
||||
// Create subscription across all relays
|
||||
const sub = this.pool.sub(this.relays, [filter]);
|
||||
|
||||
sub.on("event", (event) => {
|
||||
try {
|
||||
const content = JSON.parse(event.content);
|
||||
const video = convertEventToVideo(event);
|
||||
this.allEvents.set(event.id, video);
|
||||
|
||||
// If it’s marked deleted, remove from active map if it’s the active version
|
||||
// NEW CODE
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
// Don't compare IDs—just remove that key from the active map
|
||||
this.activeMap.delete(activeKey);
|
||||
|
||||
// (Optional) If you want a debug log:
|
||||
// console.log(`[DELETE] Removed activeKey=${activeKey}`);
|
||||
|
||||
// If marked deleted
|
||||
if (content.deleted === true) {
|
||||
// Remove it from our Map if we had it
|
||||
if (this.subscribedVideos.has(event.id)) {
|
||||
this.subscribedVideos.delete(event.id);
|
||||
// Optionally notify the callback so UI can remove it
|
||||
// onVideo(null, { deletedId: event.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct a video object
|
||||
const video = {
|
||||
id: event.id,
|
||||
version: content.version ?? 1,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
};
|
||||
|
||||
// Check if we already have it in our Map
|
||||
if (!this.subscribedVideos.has(event.id)) {
|
||||
// It's new, so store it
|
||||
this.subscribedVideos.set(event.id, video);
|
||||
// Then notify the callback that a new video arrived
|
||||
// Not deleted => see if it’s the newest
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
if (!prevActive) {
|
||||
// brand new => set it
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
} else {
|
||||
// Optional: if you want to detect edits, compare the new vs. old and update
|
||||
// this.subscribedVideos.set(event.id, video);
|
||||
// onVideo(video) to re-render, etc.
|
||||
// compare timestamps
|
||||
if (video.created_at > prevActive.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error parsing event:", err);
|
||||
console.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -542,61 +537,60 @@ class NostrClient {
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Reached EOSE for all relays");
|
||||
}
|
||||
// Optionally: onVideo(null, { eose: true }) to signal initial load done
|
||||
});
|
||||
|
||||
return sub; // so you can unsub later if needed
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-time, bulk fetch of videos from all configured relays.
|
||||
* (Limit has been reduced to 300 for better performance.)
|
||||
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
|
||||
*/
|
||||
async fetchVideos() {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 300, // Reduced from 1000 for quicker fetches
|
||||
limit: 300,
|
||||
since: 0,
|
||||
};
|
||||
const videoEvents = new Map();
|
||||
|
||||
const localAll = new Map();
|
||||
try {
|
||||
// Query each relay in parallel
|
||||
// 1) Fetch all events from each relay
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
try {
|
||||
const content = JSON.parse(evt.content);
|
||||
if (content.deleted) {
|
||||
videoEvents.delete(evt.id);
|
||||
} else {
|
||||
videoEvents.set(evt.id, {
|
||||
id: evt.id,
|
||||
pubkey: evt.pubkey,
|
||||
created_at: evt.created_at,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
isPrivate: content.isPrivate || false,
|
||||
tags: evt.tags,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing event content:", e);
|
||||
}
|
||||
const vid = convertEventToVideo(evt);
|
||||
localAll.set(evt.id, vid);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Turn the Map into a sorted array
|
||||
const allVideos = Array.from(videoEvents.values()).sort(
|
||||
// 2) Merge into this.allEvents
|
||||
for (const [id, vid] of localAll.entries()) {
|
||||
this.allEvents.set(id, vid);
|
||||
}
|
||||
|
||||
// 3) Rebuild activeMap
|
||||
this.activeMap.clear();
|
||||
for (const [id, video] of this.allEvents.entries()) {
|
||||
// Skip if the video is marked deleted
|
||||
if (video.deleted) continue;
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
|
||||
// If there's no existing entry or this is newer, set/replace
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Return newest version for each root in descending order
|
||||
const activeVideos = Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return allVideos;
|
||||
return activeVideos;
|
||||
} catch (err) {
|
||||
console.error("fetchVideos error:", err);
|
||||
return [];
|
||||
@@ -604,46 +598,38 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates video content structure.
|
||||
* Attempt to fetch an event by ID from local cache, then from the relays
|
||||
*/
|
||||
isValidVideo(content) {
|
||||
try {
|
||||
const isValid =
|
||||
content &&
|
||||
typeof content === "object" &&
|
||||
typeof content.title === "string" &&
|
||||
content.title.length > 0 &&
|
||||
typeof content.magnet === "string" &&
|
||||
content.magnet.length > 0 &&
|
||||
typeof content.mode === "string" &&
|
||||
["dev", "live"].includes(content.mode) &&
|
||||
(typeof content.thumbnail === "string" ||
|
||||
typeof content.thumbnail === "undefined") &&
|
||||
(typeof content.description === "string" ||
|
||||
typeof content.description === "undefined");
|
||||
|
||||
if (isDevMode && !isValid) {
|
||||
console.log("Invalid video content:", content);
|
||||
console.log("Validation details:", {
|
||||
hasTitle: typeof content.title === "string",
|
||||
hasMagnet: typeof content.magnet === "string",
|
||||
hasMode: typeof content.mode === "string",
|
||||
validThumbnail:
|
||||
typeof content.thumbnail === "string" ||
|
||||
typeof content.thumbnail === "undefined",
|
||||
validDescription:
|
||||
typeof content.description === "string" ||
|
||||
typeof content.description === "undefined",
|
||||
});
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Error validating video:", error);
|
||||
}
|
||||
return false;
|
||||
async getEventById(eventId) {
|
||||
const local = this.allEvents.get(eventId);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
// direct fetch if missing
|
||||
try {
|
||||
for (const url of this.relays) {
|
||||
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
|
||||
if (maybeEvt && maybeEvt.id === eventId) {
|
||||
const video = convertEventToVideo(maybeEvt);
|
||||
this.allEvents.set(eventId, video);
|
||||
return video;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("getEventById direct fetch error:", err);
|
||||
}
|
||||
}
|
||||
return null; // not found
|
||||
}
|
||||
|
||||
/**
|
||||
* Return newest versions from activeMap if you want to skip older events
|
||||
*/
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
17
src/js/viewManager.js
Normal file
17
src/js/viewManager.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// js/viewManager.js
|
||||
|
||||
// Load a partial view by URL into the #viewContainer
|
||||
export async function loadView(viewUrl) {
|
||||
try {
|
||||
const res = await fetch(viewUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load view: ${res.status}`);
|
||||
}
|
||||
const html = await res.text();
|
||||
document.getElementById("viewContainer").innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error("View loading error:", err);
|
||||
document.getElementById("viewContainer").innerHTML =
|
||||
"<p class='text-center text-red-500'>Failed to load content.</p>";
|
||||
}
|
||||
}
|
@@ -1,16 +1,14 @@
|
||||
// <!-- keep this <ai_context> section if it already exists at the top of your file -->
|
||||
|
||||
// js/webtorrent.js
|
||||
|
||||
import WebTorrent from "./webtorrent.min.js";
|
||||
|
||||
export class TorrentClient {
|
||||
constructor() {
|
||||
// Create WebTorrent client
|
||||
this.client = new WebTorrent();
|
||||
this.currentTorrent = null;
|
||||
this.TIMEOUT_DURATION = 60000; // 60 seconds
|
||||
this.statsInterval = null;
|
||||
// We remove the “statsInterval” since we’re not using it here anymore
|
||||
// this.statsInterval = null;
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
@@ -62,9 +60,6 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the service worker, waiting until it's fully active before proceeding.
|
||||
*/
|
||||
async setupServiceWorker() {
|
||||
try {
|
||||
const isBraveBrowser = await this.isBrave();
|
||||
@@ -72,21 +67,18 @@ export class TorrentClient {
|
||||
if (!window.isSecureContext) {
|
||||
throw new Error("HTTPS or localhost required");
|
||||
}
|
||||
|
||||
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
||||
throw new Error("Service Worker not supported or disabled");
|
||||
}
|
||||
|
||||
// If Brave, we optionally clear all service workers so we can re-register cleanly
|
||||
// Optional Brave config
|
||||
if (isBraveBrowser) {
|
||||
this.log("Checking Brave configuration...");
|
||||
|
||||
if (!navigator.serviceWorker) {
|
||||
throw new Error(
|
||||
"Please enable Service Workers in Brave Shield settings"
|
||||
);
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Please enable WebRTC in Brave Shield settings");
|
||||
}
|
||||
@@ -134,11 +126,9 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for service worker to become active
|
||||
await this.waitForServiceWorkerActivation(registration);
|
||||
this.log("Service worker activated");
|
||||
|
||||
// Make sure it’s truly active
|
||||
const readyRegistration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((_, reject) =>
|
||||
@@ -170,7 +160,8 @@ export class TorrentClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the given magnet URI to the specified <video> element.
|
||||
* Streams the magnet to the <video> element.
|
||||
* No stats intervals here—just returns the torrent object.
|
||||
*/
|
||||
async streamVideo(magnetURI, videoElement) {
|
||||
try {
|
||||
@@ -180,314 +171,127 @@ export class TorrentClient {
|
||||
throw new Error("Service worker setup failed");
|
||||
}
|
||||
|
||||
// 2) Create WebTorrent server AFTER service worker is ready
|
||||
// 2) Create WebTorrent server
|
||||
this.client.createServer({ controller: registration });
|
||||
this.log("WebTorrent server created");
|
||||
|
||||
const isFirefoxBrowser = this.isFirefox();
|
||||
|
||||
if (isFirefoxBrowser) {
|
||||
// ----------------------
|
||||
// FIREFOX CODE PATH
|
||||
// (sequential, concurrency limit, smaller chunk)
|
||||
// ----------------------
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isFirefoxBrowser) {
|
||||
this.log("Starting torrent download (Firefox path)");
|
||||
this.client.add(
|
||||
magnetURI,
|
||||
{
|
||||
strategy: "sequential",
|
||||
maxWebConns: 4, // reduce concurrency
|
||||
},
|
||||
{ strategy: "sequential", maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// ----------------------
|
||||
// CHROME / OTHER BROWSERS CODE PATH
|
||||
// (your original "faster" approach)
|
||||
// ----------------------
|
||||
return new Promise((resolve, reject) => {
|
||||
} else {
|
||||
this.log("Starting torrent download (Chrome path)");
|
||||
this.client.add(magnetURI, (torrent) => {
|
||||
this.log("Torrent added (Chrome path):", torrent.name);
|
||||
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.log("Failed to setup video streaming:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "faster" original approach for Chrome/other browsers.
|
||||
*/
|
||||
// Minimal handleChromeTorrent — no internal setInterval
|
||||
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
||||
this.log("Torrent added (Chrome path): " + torrent.name);
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const progress = document.getElementById("progress");
|
||||
const peers = document.getElementById("peers");
|
||||
const speed = document.getElementById("speed");
|
||||
const downloaded = document.getElementById("downloaded");
|
||||
|
||||
if (status) {
|
||||
status.textContent = `Loading ${torrent.name}...`;
|
||||
}
|
||||
|
||||
// Find playable file (same as old code)
|
||||
const file = torrent.files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".mp4") ||
|
||||
f.name.endsWith(".webm") ||
|
||||
f.name.endsWith(".mkv")
|
||||
const file = torrent.files.find((f) =>
|
||||
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||
);
|
||||
if (!file) {
|
||||
const error = new Error("No compatible video file found in torrent");
|
||||
this.log(error.message);
|
||||
if (status) status.textContent = "Error: No video file found";
|
||||
return reject(error);
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
// Mute for autoplay
|
||||
// Mute & crossOrigin
|
||||
videoElement.muted = true;
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
// Error handling same as old code
|
||||
// Catch video errors
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
const errObj = e.target.error;
|
||||
this.log("Video error:", errObj);
|
||||
if (errObj) {
|
||||
this.log("Error code:", errObj.code);
|
||||
this.log("Error message:", errObj.message);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent =
|
||||
"Error playing video. Try refreshing the page.";
|
||||
}
|
||||
this.log("Video error:", e.target.error);
|
||||
});
|
||||
|
||||
// Attempt autoplay
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
const playPromise = videoElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => this.log("Autoplay started (Chrome path)"))
|
||||
.catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
if (status) status.textContent = "Click to play video";
|
||||
videoElement.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
videoElement
|
||||
.play()
|
||||
.catch((err2) => this.log("Play failed:", err2));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", () => {
|
||||
this.log("Video metadata loaded (Chrome path)");
|
||||
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
|
||||
this.log("Invalid duration, attempting to fix...");
|
||||
videoElement.currentTime = 1e101;
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Now stream to the video element
|
||||
// Actually stream
|
||||
try {
|
||||
file.streamTo(videoElement); // no chunk constraints
|
||||
this.log("Streaming started (Chrome path)");
|
||||
|
||||
// Update stats
|
||||
this.statsInterval = setInterval(() => {
|
||||
if (!document.body.contains(videoElement)) {
|
||||
clearInterval(this.statsInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const percentage = torrent.progress * 100;
|
||||
if (progress) progress.style.width = `${percentage}%`;
|
||||
if (peers) peers.textContent = `Peers: ${torrent.numPeers}`;
|
||||
if (speed) {
|
||||
speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s`;
|
||||
}
|
||||
if (downloaded) {
|
||||
downloaded.textContent = `${this.formatBytes(
|
||||
torrent.downloaded
|
||||
)} / ${this.formatBytes(torrent.length)}`;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent =
|
||||
torrent.progress === 1
|
||||
? `${torrent.name}`
|
||||
: `Loading ${torrent.name}...`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
file.streamTo(videoElement);
|
||||
this.currentTorrent = torrent;
|
||||
resolve();
|
||||
resolve(torrent);
|
||||
} catch (err) {
|
||||
this.log("Streaming error (Chrome path):", err);
|
||||
if (status) status.textContent = "Error starting video stream";
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// Torrent error event
|
||||
// Also handle torrent error events
|
||||
torrent.on("error", (err) => {
|
||||
this.log("Torrent error (Chrome path):", err);
|
||||
if (status) status.textContent = "Error loading video";
|
||||
clearInterval(this.statsInterval);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The new approach for Firefox: sequential, concurrency limit, smaller chunk size.
|
||||
*/
|
||||
// Minimal handleFirefoxTorrent
|
||||
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
|
||||
this.log("Torrent added (Firefox path): " + torrent.name);
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const progress = document.getElementById("progress");
|
||||
const peers = document.getElementById("peers");
|
||||
const speed = document.getElementById("speed");
|
||||
const downloaded = document.getElementById("downloaded");
|
||||
|
||||
if (status) {
|
||||
status.textContent = `Loading ${torrent.name}...`;
|
||||
}
|
||||
|
||||
// Find playable file
|
||||
const file = torrent.files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".mp4") ||
|
||||
f.name.endsWith(".webm") ||
|
||||
f.name.endsWith(".mkv")
|
||||
const file = torrent.files.find((f) =>
|
||||
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||
);
|
||||
if (!file) {
|
||||
const error = new Error("No compatible video file found in torrent");
|
||||
this.log(error.message);
|
||||
if (status) status.textContent = "Error: No video file found";
|
||||
return reject(error);
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
videoElement.muted = true;
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
const errObj = e.target.error;
|
||||
this.log("Video error (Firefox path):", errObj);
|
||||
if (errObj) {
|
||||
this.log("Error code:", errObj.code);
|
||||
this.log("Error message:", errObj.message);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent =
|
||||
"Error playing video. Try refreshing the page.";
|
||||
}
|
||||
this.log("Video error (Firefox path):", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
const playPromise = videoElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => this.log("Autoplay started (Firefox path)"))
|
||||
.catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
if (status) status.textContent = "Click to play video";
|
||||
videoElement.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
videoElement
|
||||
.play()
|
||||
.catch((err2) => this.log("Play failed:", err2));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", () => {
|
||||
this.log("Video metadata loaded (Firefox path)");
|
||||
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
|
||||
this.log("Invalid duration, attempting to fix...");
|
||||
videoElement.currentTime = 1e101;
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// We set a smaller chunk size for Firefox
|
||||
try {
|
||||
file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); // 32 KB chunk
|
||||
this.log("Streaming started (Firefox path)");
|
||||
|
||||
this.statsInterval = setInterval(() => {
|
||||
if (!document.body.contains(videoElement)) {
|
||||
clearInterval(this.statsInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const percentage = torrent.progress * 100;
|
||||
if (progress) progress.style.width = `${percentage}%`;
|
||||
if (peers) peers.textContent = `Peers: ${torrent.numPeers}`;
|
||||
if (speed) {
|
||||
speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s`;
|
||||
}
|
||||
if (downloaded) {
|
||||
downloaded.textContent = `${this.formatBytes(
|
||||
torrent.downloaded
|
||||
)} / ${this.formatBytes(torrent.length)}`;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent =
|
||||
torrent.progress === 1
|
||||
? `${torrent.name}`
|
||||
: `Loading ${torrent.name}...`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
|
||||
this.currentTorrent = torrent;
|
||||
resolve();
|
||||
resolve(torrent);
|
||||
} catch (err) {
|
||||
this.log("Streaming error (Firefox path):", err);
|
||||
if (status) status.textContent = "Error starting video stream";
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// Listen for torrent errors
|
||||
torrent.on("error", (err) => {
|
||||
this.log("Torrent error (Firefox path):", err);
|
||||
if (status) status.textContent = "Error loading video";
|
||||
clearInterval(this.statsInterval);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up after playback or page unload.
|
||||
* Clean up
|
||||
*/
|
||||
async cleanup() {
|
||||
try {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
// No local interval to clear here
|
||||
if (this.currentTorrent) {
|
||||
this.currentTorrent.destroy();
|
||||
}
|
||||
if (this.client) {
|
||||
await this.client.destroy();
|
||||
// Recreate fresh client for next time
|
||||
this.client = new WebTorrent();
|
||||
}
|
||||
} catch (error) {
|
||||
|
13
src/views/most-recent-videos.html
Normal file
13
src/views/most-recent-videos.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- views/most-recent-videos.html -->
|
||||
<section>
|
||||
<!-- You can have a heading if you want -->
|
||||
<h2 class="text-xl mb-4 text-gray-700">Most Recent Videos</h2>
|
||||
|
||||
<!-- Video List -->
|
||||
<div
|
||||
id="videoList"
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||
>
|
||||
<!-- Videos will be dynamically inserted here -->
|
||||
</div>
|
||||
</section>
|
Reference in New Issue
Block a user