mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-09 07:28:44 +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**
|
# **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** |
|
| **Field** | **Type** | **Description** |
|
||||||
|---------------|----------------|--------------------------------------------------------------------|
|
| ------------ | ------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| `kind` | Integer | Fixed as `30078` for media-sharing events (video, music, etc.). |
|
| `kind` | Integer | Fixed at `30078` for these media notes. |
|
||||||
| `pubkey` | String | Public key of the note creator. |
|
| `pubkey` | String | Creator’s pubkey in hex. |
|
||||||
| `created_at` | Integer | Unix timestamp (seconds). |
|
| `created_at` | Integer | Unix timestamp (seconds). |
|
||||||
| `tags` | Array of Arrays| Includes metadata such as `["t", "video"]` or `["t", "music"]` and `["d", "<id>"]`. |
|
| `tags` | Array | Includes metadata such as `["t", "video"]` or `["t", "music"]` and `["d", "<unique-id>"]`. |
|
||||||
| `content` | JSON String | JSON object containing the post data (detailed below). |
|
| `content` | String (JSON) | JSON specifying metadata (see table below). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Post Content for Version 3
|
## **3. Version 3 Content JSON**
|
||||||
|
|
||||||
| **Field** | **Type** | **Description** |
|
| **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`. |
|
| `version` | Integer | Now set to `3`. |
|
||||||
| `deleted` | Boolean | Indicates soft deletion. |
|
| `deleted` | Boolean | `true` marks the post as “soft deleted.” |
|
||||||
| `isPrivate` | Boolean | `true` if content is private (magnet and certain optional fields may be encrypted). |
|
| `isPrivate` | Boolean | Indicates if `magnet` (and possibly other fields) are encrypted. |
|
||||||
| `title` | String | Title of the media (video title, track title, podcast episode, etc.). |
|
| `title` | String | Display title for the media. |
|
||||||
| `magnet` | String | Magnet link for the primary media file (encrypted if `isPrivate = true`). |
|
| `magnet` | String | Magnet link for primary media (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). |
|
| `extraMagnets` | Array (optional) | Additional magnet links (e.g., multiple resolutions). |
|
||||||
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image. |
|
| `thumbnail` | String (optional) | URL or magnet link to a thumbnail image (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||||
| `description` | String (optional) | Description of the media. |
|
| `description` | String (optional) | A textual description (encrypted if `isPrivate = true` and `encryptedMeta = true`). |
|
||||||
| `mode` | String | Indicates `live` or `dev` mode for streaming or test scenarios. |
|
| `mode` | String | Typically `live` or `dev`. |
|
||||||
| `adult` | Boolean (optional) | `true` if content is adult-only. Default is `false` or omitted. |
|
| `adult` | Boolean (optional) | `true` if content is adult-only. Default: `false` or omitted. |
|
||||||
| `categories` | Array (optional) | A list of categories or tags, e.g., `["comedy", "music"]`. |
|
| `categories` | Array (optional) | Array of categories, e.g. `["comedy", "music"]`. |
|
||||||
| `language` | String (optional) | Language code (e.g., `en`, `es`). |
|
| `language` | String (optional) | Language code (e.g. `"en"`, `"es"`). |
|
||||||
| `payment` | String (optional) | Monetization field, such as a Lightning address. |
|
| `payment` | String (optional) | Monetization field (e.g. a Lightning address). |
|
||||||
| `i18n` | Object (optional) | Holds internationalized fields (e.g., `{"title_en": "Hello", "title_es": "Hola"}`). |
|
| `i18n` | Object (optional) | Internationalization map (e.g. `{"title_en": "...", "description_es": "..."}`). |
|
||||||
| `encryptedMeta` | Boolean (optional) | If `true`, indicates fields like `description` or `thumbnail` may be encrypted. |
|
| `encryptedMeta` | Boolean (optional) | Indicates if fields like `description` or `thumbnail` are encrypted. |
|
||||||
| **Audio/Podcast-Specific Fields** | | |
|
| **Audio/Podcast-Specific Fields** | | |
|
||||||
| `contentType` | String (optional) | Type of media, e.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
| `contentType` | String (optional) | E.g., `"video"`, `"music"`, `"podcast"`, `"audiobook"`. |
|
||||||
| `albumName` | String (optional) | Name of the album (if part of a music album). |
|
| `albumName` | String (optional) | Name of the album (for music). |
|
||||||
| `trackNumber` | Integer (optional) | Track number in an album. |
|
| `trackNumber` | Integer (optional) | Track number in an album. |
|
||||||
| `trackTitle` | String (optional) | Track title if different from `title`. |
|
| `trackTitle` | String (optional) | Track title if different from `title`. |
|
||||||
| `podcastSeries` | String (optional) | Name of the podcast series. |
|
| `podcastSeries` | String (optional) | Name of the podcast series. |
|
||||||
| `seasonNumber` | Integer (optional) | Season number for a podcast. |
|
| `seasonNumber` | Integer (optional) | Season number for a podcast. |
|
||||||
| `episodeNumber` | Integer (optional) | Episode number for a podcast series. |
|
| `episodeNumber` | Integer (optional) | Episode number for a podcast series. |
|
||||||
| `duration` | Integer (optional) | Duration in seconds (useful for audio or video players). |
|
| `duration` | Integer (optional) | Duration in seconds (useful for audio or video players). |
|
||||||
| `artistName` | String (optional) | Main artist or presenter. |
|
| `artistName` | String (optional) | Artist or presenter name. |
|
||||||
| `contributors` | Array (optional) | List of additional contributors, e.g., `[{"name": "John", "role": "Producer"}]`. |
|
| `contributors` | Array (optional) | List of additional contributors, e.g. `[{"name": "X", "role": "Producer"}]`. |
|
||||||
| `audioQuality` | Array (optional) | Multiple magnet links with different audio formats or bitrates. Example: `[{"quality": "lossless", "magnet": "..."}]`.|
|
| `audioQuality` | Array (optional) | Array of objects indicating different audio bitrates/formats, each with a magnet link. |
|
||||||
| `playlist` | Array (optional) | Array of magnet links or references forming a playlist (useful for albums or sets). |
|
| `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**: Ensures all edits and a final delete event share the same “root.”
|
||||||
- Purpose: Quick lookups for content type, unique IDs, or user-defined categories.
|
- **Edit**: Keep the same `videoRootId` in the new content so clients know it’s an update of the same item.
|
||||||
- Examples:
|
- **Delete**: Reuse the same `videoRootId` (and typically the same `d` tag). Mark `deleted = true` to overshadow the old event.
|
||||||
- `["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`.
|
|
||||||
|
|
||||||
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
|
## **5. Example Post: Version 3 (Video)**
|
||||||
|
|
||||||
### 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)
|
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -196,10 +93,10 @@ This process helps developers maintain a consistent approach to adult content ha
|
|||||||
"created_at": 1700000000,
|
"created_at": 1700000000,
|
||||||
"tags": [
|
"tags": [
|
||||||
["t", "video"],
|
["t", "video"],
|
||||||
["d", "unique-identifier"]
|
["d", "my-unique-handle"]
|
||||||
// ["adult", "true"] // optional if storing adult info in tags
|
|
||||||
],
|
],
|
||||||
"content": "{
|
"content": "{
|
||||||
|
\"videoRootId\": \"root-1678551042-abc123\",
|
||||||
\"version\": 3,
|
\"version\": 3,
|
||||||
\"deleted\": false,
|
\"deleted\": false,
|
||||||
\"isPrivate\": 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
|
```jsonc
|
||||||
{
|
{
|
||||||
"kind": 30078,
|
"kind": 30078,
|
||||||
"pubkey": "npub1...",
|
"pubkey": "npub1...",
|
||||||
"created_at": 1700000000,
|
"created_at": 1700000001,
|
||||||
"tags": [
|
"tags": [
|
||||||
["t", "music"],
|
["t", "music"],
|
||||||
["d", "unique-id-for-track"]
|
["d", "my-song-handle"]
|
||||||
],
|
],
|
||||||
"content": "{
|
"content": "{
|
||||||
|
\"videoRootId\": \"root-1678551042-xyz999\",
|
||||||
\"version\": 3,
|
\"version\": 3,
|
||||||
\"deleted\": false,
|
\"deleted\": false,
|
||||||
\"isPrivate\": 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\"
|
\"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\"],
|
\"categories\": [\"music\", \"pop\"],
|
||||||
\"contributors\": [
|
\"contributors\": [
|
||||||
{ \"name\": \"John Doe\", \"role\": \"Producer\" },
|
{ \"name\": \"John Doe\", \"role\": \"Producer\" },
|
||||||
@@ -269,62 +170,127 @@ This process helps developers maintain a consistent approach to adult content ha
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- **videoRootId**: `root-1678551042-xyz999` used to link edits/deletes.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Example Post: Version 3 (Playlist or Album)
|
## **7. Tagging**
|
||||||
|
|
||||||
```jsonc
|
- `["t", "video"]` or `["t", "music"]` for quick type references.
|
||||||
{
|
- `["d", "<unique-handle>"]` for a stable “address” pointer.
|
||||||
"kind": 30078,
|
- You can store adult flags or categories in tags, e.g. `["adult", "true"]` or `["category", "comedy"]`.
|
||||||
"pubkey": "npub1...",
|
- However, storing them inside `content` (e.g. `adult=true` or `categories=["comedy"]`) is generally recommended so older clients can ignore them gracefully.
|
||||||
"created_at": 1700000000,
|
|
||||||
"tags": [
|
---
|
||||||
["t", "playlist"],
|
|
||||||
["d", "unique-id-for-playlist"]
|
## **8. Handling Edits and Deletes**
|
||||||
],
|
|
||||||
|
### **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": "{
|
"content": "{
|
||||||
|
\"videoRootId\": \"root-1234\",
|
||||||
\"version\": 3,
|
\"version\": 3,
|
||||||
\"deleted\": false,
|
\"deleted\": false,
|
||||||
\"isPrivate\": false,
|
\"title\": \"My Video\",
|
||||||
\"title\": \"Chill Vibes Playlist\",
|
\"magnet\": \"magnet:?xt=hash\"
|
||||||
\"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\"]
|
|
||||||
}"
|
}"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Transition Plan from Version 2 to 3
|
## **12. Backward Compatibility**
|
||||||
|
|
||||||
1. **Backward Compatibility**
|
- **Version 2** fields remain recognized: `title`, `magnet`, `mode`, etc.
|
||||||
- All new fields are optional.
|
- **Older Clients**: Ignore fields like `videoRootId`, `categories`, `adult`, etc.
|
||||||
- Version 2 clients still see basic fields like `title` and `magnet`.
|
- **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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Summary
|
## **13. Summary**
|
||||||
|
|
||||||
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.
|
**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 {
|
:root {
|
||||||
--color-bg: #0f172a;
|
--color-bg: #0f172a;
|
||||||
--color-card: #1e293b;
|
--color-card: #1e293b;
|
||||||
--color-primary: #f43f5e;
|
--color-primary: #fe0032;
|
||||||
--color-secondary: #ff93a5;
|
--color-secondary: #ff93a5;
|
||||||
--color-text: #f8fafc;
|
--color-text: #f8fafc;
|
||||||
--color-muted: #94a3b8;
|
--color-muted: #94a3b8;
|
||||||
@@ -229,7 +229,11 @@ textarea:focus {
|
|||||||
ring: 2px var(--color-primary);
|
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) {
|
button:not(.icon-button) {
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
background-color: var(--color-primary);
|
background-color: var(--color-primary);
|
||||||
@@ -248,6 +252,7 @@ button:not(.icon-button):focus {
|
|||||||
outline: none;
|
outline: none;
|
||||||
ring: 2px var(--color-primary);
|
ring: 2px var(--color-primary);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
/* Utility Classes */
|
/* Utility Classes */
|
||||||
.line-clamp-2 {
|
.line-clamp-2 {
|
||||||
@@ -262,25 +267,19 @@ button:not(.icon-button):focus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Notifications */
|
/* Notifications */
|
||||||
|
/* Base styling without a forced display */
|
||||||
#errorContainer,
|
#errorContainer,
|
||||||
#successContainer {
|
#successContainer {
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin-bottom: 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;
|
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 */
|
/* Responsive Design */
|
||||||
@@ -425,18 +424,13 @@ footer a:hover {
|
|||||||
backdrop-filter: blur(4px);
|
backdrop-filter: blur(4px);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- New Classes for Icon Buttons & Images --- */
|
/* Circular Icon Buttons */
|
||||||
|
|
||||||
/* Circular icon buttons */
|
|
||||||
.icon-button {
|
.icon-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
/* Fixed width/height for a perfect circle */
|
|
||||||
width: 2.5rem; /* 40px */
|
width: 2.5rem; /* 40px */
|
||||||
height: 2.5rem; /* 40px */
|
height: 2.5rem; /* 40px */
|
||||||
|
|
||||||
line-height: 0;
|
line-height: 0;
|
||||||
background-color: #3f3f46; /* Gray 700 */
|
background-color: #3f3f46; /* Gray 700 */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
@@ -446,40 +440,28 @@ footer a:hover {
|
|||||||
transition: background-color 0.2s, box-shadow 0.2s;
|
transition: background-color 0.2s, box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover state: slightly lighter gray */
|
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
background-color: #52525b; /* Gray 600 */
|
background-color: #52525b; /* Gray 600 */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus/active states: red ring */
|
|
||||||
.icon-button:focus,
|
.icon-button:focus,
|
||||||
.icon-button:active {
|
.icon-button:active {
|
||||||
outline: none;
|
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 {
|
.icon-image {
|
||||||
width: 1.25rem; /* 20px */
|
width: 1.25rem; /* 20px */
|
||||||
height: 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;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Create a container that preserves a 16:9 aspect ratio via padding-top. */
|
/* Ratio 16:9 Container */
|
||||||
/* Force 16:9 ratio using padding-top technique */
|
|
||||||
.ratio-16-9 {
|
.ratio-16-9 {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-top: 56.25%; /* 16:9 => 9/16 = 0.5625 => 56.25% */
|
padding-top: 56.25%;
|
||||||
background-color: #1e293b; /* fallback background if image doesn't load */
|
background-color: #1e293b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ratio-16-9 > img {
|
.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
212
src/index.html
212
src/index.html
@@ -1,34 +1,24 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>bitvid | Decentralized Video Sharing</title>
|
<title>bitvid | Decentralized Video Sharing</title>
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta
|
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
||||||
property="og:title"
|
|
||||||
content="BitVid - Decentralized Video Sharing"
|
|
||||||
/>
|
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Share videos and follow creators freely, in a truly decentralized way."
|
content="Share videos and follow creators freely, in a truly decentralized way."
|
||||||
/>
|
/>
|
||||||
<meta
|
<meta property="og:image" content="assets/jpg/bitvid.jpg" />
|
||||||
property="og:image"
|
|
||||||
content="https://bitvid.netlify.app/assets/jpg/bitvid.jpg"
|
|
||||||
/>
|
|
||||||
<meta property="og:url" content="https://bitvid.network" />
|
<meta property="og:url" content="https://bitvid.network" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:locale" content="en_US" />
|
<meta property="og:locale" content="en_US" />
|
||||||
|
|
||||||
<!-- App Icons -->
|
<!-- App Icons -->
|
||||||
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
||||||
<link
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
rel="apple-touch-icon"
|
|
||||||
sizes="180x180"
|
|
||||||
href="/apple-touch-icon.png"
|
|
||||||
/>
|
|
||||||
<link
|
<link
|
||||||
rel="icon"
|
rel="icon"
|
||||||
type="image/png"
|
type="image/png"
|
||||||
@@ -45,16 +35,9 @@
|
|||||||
<meta name="theme-color" content="#0f172a" />
|
<meta name="theme-color" content="#0f172a" />
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Styles -->
|
||||||
<link
|
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||||
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link href="css/style.css" rel="stylesheet" />
|
<link href="css/style.css" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
|
||||||
<!-- Rest of your page content -->
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
|
|
||||||
<body class="bg-gray-100">
|
<body class="bg-gray-100">
|
||||||
<div
|
<div
|
||||||
@@ -62,43 +45,56 @@
|
|||||||
class="container mx-auto px-4 py-8 min-h-screen flex flex-col"
|
class="container mx-auto px-4 py-8 min-h-screen flex flex-col"
|
||||||
>
|
>
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="mb-8">
|
<header class="mb-8 flex items-center">
|
||||||
<div class="flex items-start">
|
<!-- Logo on the left -->
|
||||||
<img
|
<img
|
||||||
src="assets/svg/bitvid-logo-light-mode.svg"
|
src="assets/svg/bitvid-logo-light-mode.svg"
|
||||||
alt="BitVid Logo"
|
alt="BitVid Logo"
|
||||||
class="h-16"
|
class="h-16"
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Login Section -->
|
<!-- Buttons on the far right -->
|
||||||
<div id="loginSection" class="mb-8 flex items-center justify-between">
|
<div class="ml-auto flex items-center space-x-4">
|
||||||
<div>
|
<!-- Login Button -->
|
||||||
<button
|
<button
|
||||||
id="loginButton"
|
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>
|
</button>
|
||||||
<p id="userStatus" class="mt-4 text-gray-500 hidden">
|
|
||||||
Logged in as: <span id="userPubKey"></span>
|
<!-- Upload (Add Video) Button -->
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
<button
|
||||||
id="logoutButton"
|
id="uploadButton"
|
||||||
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</header>
|
||||||
|
|
||||||
<!-- Error Container -->
|
<!-- Error Container -->
|
||||||
<div
|
<div
|
||||||
id="errorContainer"
|
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 -->
|
<!-- Error messages will appear here -->
|
||||||
</div>
|
</div>
|
||||||
@@ -106,127 +102,20 @@
|
|||||||
<!-- Success Container -->
|
<!-- Success Container -->
|
||||||
<div
|
<div
|
||||||
id="successContainer"
|
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 -->
|
<!-- Success messages will appear here -->
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Video Submission Form -->
|
<!-- Main container for dynamic views -->
|
||||||
<div
|
<main id="viewContainer" class="flex-grow mb-8">
|
||||||
class="bg-white p-6 rounded-lg shadow-md mb-8 hidden"
|
<!-- We'll load "most-recent-videos.html" or other views here -->
|
||||||
id="videoFormContainer"
|
</main>
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div>
|
<!-- Imported Video Player Modal (goes into modalContainer) -->
|
||||||
<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 -->
|
|
||||||
<div id="modalContainer"></div>
|
<div id="modalContainer"></div>
|
||||||
|
|
||||||
|
<!-- Tagline / Slogan -->
|
||||||
<div class="text-center mb-8">
|
<div class="text-center mb-8">
|
||||||
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
||||||
seed. zap. subscribe.
|
seed. zap. subscribe.
|
||||||
@@ -237,7 +126,6 @@
|
|||||||
<div id="disclaimerModal" class="hidden">
|
<div id="disclaimerModal" class="hidden">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<div class="modal-scroll">
|
<div class="modal-scroll">
|
||||||
<!-- Logo -->
|
|
||||||
<div class="flex justify-center mb-8">
|
<div class="flex justify-center mb-8">
|
||||||
<img
|
<img
|
||||||
src="assets/svg/bitvid-logo-dark-mode.svg"
|
src="assets/svg/bitvid-logo-dark-mode.svg"
|
||||||
@@ -276,7 +164,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content -->
|
|
||||||
<div class="space-y-6 text-gray-300">
|
<div class="space-y-6 text-gray-300">
|
||||||
<p>
|
<p>
|
||||||
bitvid is a decentralized video platform where content is shared
|
bitvid is a decentralized video platform where content is shared
|
||||||
@@ -330,7 +217,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Button in fixed container -->
|
|
||||||
<div class="button-container">
|
<div class="button-container">
|
||||||
<button
|
<button
|
||||||
id="acceptDisclaimer"
|
id="acceptDisclaimer"
|
||||||
@@ -440,17 +326,17 @@
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- Scripts -->
|
<!-- 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 src="js/libs/nostr.bundle.js"></script>
|
||||||
<script type="module" src="js/config.js"></script>
|
<script type="module" src="js/config.js"></script>
|
||||||
<script type="module" src="js/lists.js"></script>
|
<script type="module" src="js/lists.js"></script>
|
||||||
<script type="module" src="js/accessControl.js"></script>
|
<script type="module" src="js/accessControl.js"></script>
|
||||||
<script type="module" src="js/webtorrent.js"></script>
|
<script type="module" src="js/webtorrent.js"></script>
|
||||||
<script type="module" src="js/nostr.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>
|
<script type="module" src="js/app.js"></script>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
1160
src/js/app.js
1160
src/js/app.js
File diff suppressed because it is too large
Load Diff
550
src/js/nostr.js
550
src/js/nostr.js
@@ -11,10 +11,9 @@ const RELAY_URLS = [
|
|||||||
"wss://relay.nostr.band",
|
"wss://relay.nostr.band",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Rate limiting for error logs
|
// Just a helper to keep error spam in check
|
||||||
let errorLogCount = 0;
|
let errorLogCount = 0;
|
||||||
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
const MAX_ERROR_LOGS = 100;
|
||||||
|
|
||||||
function logErrorOnce(message, eventContent = null) {
|
function logErrorOnce(message, eventContent = null) {
|
||||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
if (errorLogCount < MAX_ERROR_LOGS) {
|
||||||
console.error(message);
|
console.error(message);
|
||||||
@@ -31,8 +30,8 @@ function logErrorOnce(message, eventContent = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A very naive "encryption" function that just reverses the string.
|
* Example "encryption" that just reverses strings.
|
||||||
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
* In real usage, swap with actual crypto.
|
||||||
*/
|
*/
|
||||||
function fakeEncrypt(magnet) {
|
function fakeEncrypt(magnet) {
|
||||||
return magnet.split("").reverse().join("");
|
return magnet.split("").reverse().join("");
|
||||||
@@ -41,18 +40,63 @@ function fakeDecrypt(encrypted) {
|
|||||||
return encrypted.split("").reverse().join("");
|
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 {
|
class NostrClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pool = null;
|
this.pool = null;
|
||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
this.relays = RELAY_URLS;
|
this.relays = RELAY_URLS;
|
||||||
|
|
||||||
// We keep a Map of subscribed videos for quick lookups by event.id
|
// All events—old or new—so older share links still work
|
||||||
this.subscribedVideos = new Map();
|
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() {
|
async init() {
|
||||||
if (isDevMode) console.log("Connecting to relays...");
|
if (isDevMode) console.log("Connecting to relays...");
|
||||||
@@ -63,18 +107,16 @@ class NostrClient {
|
|||||||
const successfulRelays = results
|
const successfulRelays = results
|
||||||
.filter((r) => r.success)
|
.filter((r) => r.success)
|
||||||
.map((r) => r.url);
|
.map((r) => r.url);
|
||||||
|
|
||||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
||||||
|
if (isDevMode) {
|
||||||
if (isDevMode)
|
|
||||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Nostr init failed:", err);
|
console.error("Nostr init failed:", err);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to handle relay connections
|
|
||||||
async connectToRelays() {
|
async connectToRelays() {
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
this.relays.map(
|
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() {
|
async login() {
|
||||||
try {
|
try {
|
||||||
if (!window.nostr) {
|
if (!window.nostr) {
|
||||||
console.log("No Nostr extension found");
|
console.log("No Nostr extension found");
|
||||||
throw new Error(
|
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 pubkey = await window.nostr.getPublicKey();
|
||||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||||
|
|
||||||
// Debug logs
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("Got pubkey:", pubkey);
|
console.log("Got pubkey:", pubkey);
|
||||||
console.log("Converted to npub:", npub);
|
console.log("Converted to npub:", npub);
|
||||||
console.log("Whitelist:", accessControl.getWhitelist());
|
console.log("Whitelist:", accessControl.getWhitelist());
|
||||||
console.log("Blacklist:", accessControl.getBlacklist());
|
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.canAccess(npub)) {
|
||||||
if (accessControl.isBlacklisted(npub)) {
|
if (accessControl.isBlacklisted(npub)) {
|
||||||
throw new Error(
|
throw new Error("Your account has been blocked on this platform.");
|
||||||
"Your account has been blocked from accessing this platform."
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error("Access restricted to whitelisted users only.");
|
||||||
"Access is currently restricted to whitelisted users only."
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.pubkey = pubkey;
|
this.pubkey = pubkey;
|
||||||
if (isDevMode)
|
if (isDevMode) {
|
||||||
console.log(
|
console.log("Logged in with extension. Pubkey:", this.pubkey);
|
||||||
"Successfully logged in with extension. Public key:",
|
}
|
||||||
this.pubkey
|
|
||||||
);
|
|
||||||
return this.pubkey;
|
return this.pubkey;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Login error:", e);
|
console.error("Login error:", e);
|
||||||
@@ -150,17 +183,11 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Logs out the user.
|
|
||||||
*/
|
|
||||||
logout() {
|
logout() {
|
||||||
this.pubkey = null;
|
this.pubkey = null;
|
||||||
if (isDevMode) console.log("User logged out.");
|
if (isDevMode) console.log("User logged out.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes an NSEC key.
|
|
||||||
*/
|
|
||||||
decodeNsec(nsec) {
|
decodeNsec(nsec) {
|
||||||
try {
|
try {
|
||||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
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) {
|
async publishVideo(videoData, pubkey) {
|
||||||
if (!pubkey) {
|
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||||
throw new Error("User is not logged in.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDevMode) {
|
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;
|
let finalMagnet = videoData.magnet;
|
||||||
if (videoData.isPrivate === true) {
|
if (videoData.isPrivate) {
|
||||||
finalMagnet = fakeEncrypt(finalMagnet);
|
finalMagnet = fakeEncrypt(finalMagnet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default version is 1 if not specified
|
// new "videoRootId" ensures all future edits know they're from the same root
|
||||||
const version = videoData.version ?? 1;
|
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 = {
|
const contentObject = {
|
||||||
version,
|
videoRootId,
|
||||||
|
version: videoData.version ?? 1,
|
||||||
deleted: false,
|
deleted: false,
|
||||||
isPrivate: videoData.isPrivate || false,
|
isPrivate: videoData.isPrivate ?? false,
|
||||||
title: videoData.title,
|
title: videoData.title || "",
|
||||||
magnet: finalMagnet,
|
magnet: finalMagnet,
|
||||||
thumbnail: videoData.thumbnail,
|
thumbnail: videoData.thumbnail || "",
|
||||||
description: videoData.description,
|
description: videoData.description || "",
|
||||||
mode: videoData.mode,
|
mode: videoData.mode || "live",
|
||||||
};
|
};
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 100),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags: [
|
||||||
["t", "video"],
|
["t", "video"],
|
||||||
["d", uniqueD],
|
["d", dTagValue],
|
||||||
],
|
],
|
||||||
content: JSON.stringify(contentObject),
|
content: JSON.stringify(contentObject),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("Event content after stringify:", event.content);
|
console.log("Publish event with brand-new root:", videoRootId);
|
||||||
console.log("Using d tag:", uniqueD);
|
console.log("Event content:", event.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
if (isDevMode) {
|
if (isDevMode) console.log("Signed event:", signedEvent);
|
||||||
console.log("Signed event:", signedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.relays.map(async (url) => {
|
this.relays.map(async (url) => {
|
||||||
try {
|
try {
|
||||||
await this.pool.publish([url], signedEvent);
|
await this.pool.publish([url], signedEvent);
|
||||||
if (isDevMode) {
|
if (isDevMode) console.log(`Video published to ${url}`);
|
||||||
console.log(`Event published to ${url}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
|
||||||
console.error(`Failed to publish to ${url}:`, err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return signedEvent;
|
return signedEvent;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||||
console.error("Failed to sign event:", error.message);
|
throw err;
|
||||||
}
|
|
||||||
throw new Error("Failed to sign event.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Edits an existing video event by reusing the same "d" tag.
|
* Edits a video by creating a *new event* with a brand-new d tag,
|
||||||
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
* 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) {
|
async editVideo(originalVideo, updatedData, pubkey) {
|
||||||
if (!pubkey) {
|
if (!pubkey) throw new Error("Not logged in to edit.");
|
||||||
throw new Error("User is not logged in.");
|
if (originalVideo.pubkey !== pubkey) {
|
||||||
}
|
throw new Error("You do not own this video (different pubkey).");
|
||||||
if (originalEvent.pubkey !== pubkey) {
|
|
||||||
throw new Error("You do not own this event (different pubkey).");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isDevMode) {
|
// Use the videoRootId directly from the converted video
|
||||||
console.log("Editing video event:", originalEvent);
|
const rootId = originalVideo.videoRootId || null;
|
||||||
console.log("New video data:", updatedVideoData);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Grab the d tag from the original event
|
// Decrypt the old magnet if it was private
|
||||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
let oldPlainMagnet = originalVideo.magnet || "";
|
||||||
if (!dTag) {
|
if (originalVideo.isPrivate && oldPlainMagnet) {
|
||||||
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) {
|
|
||||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
// Determine new privacy setting
|
||||||
const newIsPrivate =
|
const wantPrivate =
|
||||||
typeof updatedVideoData.isPrivate === "boolean"
|
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
|
||||||
? updatedVideoData.isPrivate
|
|
||||||
: oldContent.isPrivate ?? false;
|
|
||||||
|
|
||||||
// 3) The user might type a new magnet or keep oldPlainMagnet
|
// Fallback to old magnet if none provided
|
||||||
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
let finalPlainMagnet = (updatedData.magnet || "").trim();
|
||||||
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
if (!finalPlainMagnet) {
|
||||||
|
finalPlainMagnet = oldPlainMagnet;
|
||||||
|
}
|
||||||
|
|
||||||
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
// Re-encrypt if user wants private
|
||||||
let finalMagnet = finalPlainMagnet;
|
let finalMagnet = finalPlainMagnet;
|
||||||
if (newIsPrivate) {
|
if (wantPrivate) {
|
||||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
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
|
// Build updated content
|
||||||
const contentObject = {
|
const contentObject = {
|
||||||
version: newVersion,
|
videoRootId: newRootId,
|
||||||
deleted: oldDeleted,
|
version: updatedData.version ?? originalVideo.version ?? 1,
|
||||||
isPrivate: newIsPrivate,
|
deleted: false,
|
||||||
title: updatedVideoData.title,
|
isPrivate: wantPrivate,
|
||||||
|
title: updatedData.title ?? originalVideo.title,
|
||||||
magnet: finalMagnet,
|
magnet: finalMagnet,
|
||||||
thumbnail: updatedVideoData.thumbnail,
|
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
|
||||||
description: updatedVideoData.description,
|
description: updatedData.description ?? originalVideo.description,
|
||||||
mode: updatedVideoData.mode,
|
mode: updatedData.mode ?? originalVideo.mode ?? "live",
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Building updated content object:", contentObject);
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags: [
|
||||||
["t", "video"],
|
["t", "video"],
|
||||||
["d", existingD],
|
["d", newD], // new share link
|
||||||
],
|
],
|
||||||
content: JSON.stringify(contentObject),
|
content: JSON.stringify(contentObject),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("Reusing d tag:", existingD);
|
console.log("Creating edited event with root ID:", newRootId);
|
||||||
console.log("Updated event content:", event.content);
|
console.log("Event content:", event.content);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const signedEvent = await window.nostr.signEvent(event);
|
const signedEvent = await window.nostr.signEvent(event);
|
||||||
if (isDevMode) {
|
|
||||||
console.log("Signed edited event:", signedEvent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publish to all relays
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.relays.map(async (url) => {
|
this.relays.map(async (url) => {
|
||||||
try {
|
try {
|
||||||
await this.pool.publish([url], signedEvent);
|
await this.pool.publish([url], signedEvent);
|
||||||
if (isDevMode) {
|
|
||||||
console.log(
|
|
||||||
`Edited event published to ${url} (d="${existingD}")`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.error(
|
console.error(`Publish failed to ${url}`, err);
|
||||||
`Failed to publish edited event to ${url}:`,
|
|
||||||
err.message
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return signedEvent;
|
return signedEvent;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
console.error("Edit failed:", err);
|
||||||
console.error("Failed to sign edited event:", error.message);
|
throw err;
|
||||||
}
|
|
||||||
throw new Error("Failed to sign edited event.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
|
||||||
* and republishing with the same (kind=30078, pubkey, d) address.
|
|
||||||
*/
|
*/
|
||||||
async deleteVideo(originalEvent, pubkey) {
|
async deleteVideo(originalEvent, pubkey) {
|
||||||
if (!pubkey) {
|
if (!pubkey) {
|
||||||
throw new Error("User is not logged in.");
|
throw new Error("Not logged in to delete.");
|
||||||
}
|
}
|
||||||
if (originalEvent.pubkey !== pubkey) {
|
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) {
|
// If front-end didn't pass the tags array, load the full event from local or from the relay:
|
||||||
console.log("Deleting video event:", originalEvent);
|
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) {
|
if (!dTag) {
|
||||||
throw new Error(
|
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
||||||
'This event has no "d" tag, cannot delete as addressable kind=30078.'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const existingD = dTag[1];
|
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;
|
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 = {
|
const contentObject = {
|
||||||
|
videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key
|
||||||
version: oldVersion,
|
version: oldVersion,
|
||||||
deleted: true,
|
deleted: true,
|
||||||
|
isPrivate: oldContent.isPrivate ?? false,
|
||||||
title: oldContent.title || "",
|
title: oldContent.title || "",
|
||||||
magnet: "",
|
magnet: "",
|
||||||
thumbnail: "",
|
thumbnail: "",
|
||||||
description: "This video has been deleted.",
|
description: "Video was deleted by creator.",
|
||||||
mode: oldContent.mode || "live",
|
mode: oldContent.mode || "live",
|
||||||
isPrivate: oldContent.isPrivate || false,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reuse the same d-tag for an addressable edit
|
|
||||||
const event = {
|
const event = {
|
||||||
kind: 30078,
|
kind: 30078,
|
||||||
pubkey,
|
pubkey,
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
tags: [
|
tags: [
|
||||||
["t", "video"],
|
["t", "video"],
|
||||||
|
// We reuse the same d => overshadow the original event
|
||||||
["d", existingD],
|
["d", existingD],
|
||||||
],
|
],
|
||||||
content: JSON.stringify(contentObject),
|
content: JSON.stringify(contentObject),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("Reusing d tag for delete:", existingD);
|
console.log("Deleting video => mark 'deleted:true'.", event.content);
|
||||||
console.log("Deleted event content:", event.content);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -444,14 +453,13 @@ class NostrClient {
|
|||||||
console.log("Signed deleted event:", signedEvent);
|
console.log("Signed deleted event:", signedEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Publish everywhere
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.relays.map(async (url) => {
|
this.relays.map(async (url) => {
|
||||||
try {
|
try {
|
||||||
await this.pool.publish([url], signedEvent);
|
await this.pool.publish([url], signedEvent);
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log(
|
console.log(`Delete event published to ${url}`);
|
||||||
`Deleted event published to ${url} (d="${existingD}")`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
@@ -460,80 +468,67 @@ class NostrClient {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return signedEvent;
|
return signedEvent;
|
||||||
} catch (error) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
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.");
|
throw new Error("Failed to sign deleted event.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribes to video events from all configured relays, storing them in a Map.
|
* 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
|
||||||
* @param {Function} onVideo - Callback fired for each new/updated video
|
* version of each root (or fallback).
|
||||||
*/
|
*/
|
||||||
subscribeVideos(onVideo) {
|
subscribeVideos(onVideo) {
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [30078],
|
kinds: [30078],
|
||||||
"#t": ["video"],
|
"#t": ["video"],
|
||||||
limit: 500, // Adjust as needed
|
limit: 500,
|
||||||
since: 0,
|
since: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create subscription across all relays
|
|
||||||
const sub = this.pool.sub(this.relays, [filter]);
|
const sub = this.pool.sub(this.relays, [filter]);
|
||||||
|
|
||||||
sub.on("event", (event) => {
|
sub.on("event", (event) => {
|
||||||
try {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct a video object
|
// Not deleted => see if it’s the newest
|
||||||
const video = {
|
const activeKey = getActiveKey(video);
|
||||||
id: event.id,
|
const prevActive = this.activeMap.get(activeKey);
|
||||||
version: content.version ?? 1,
|
if (!prevActive) {
|
||||||
isPrivate: content.isPrivate ?? false,
|
// brand new => set it
|
||||||
title: content.title || "",
|
this.activeMap.set(activeKey, video);
|
||||||
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
|
|
||||||
onVideo(video);
|
onVideo(video);
|
||||||
} else {
|
} else {
|
||||||
// Optional: if you want to detect edits, compare the new vs. old and update
|
// compare timestamps
|
||||||
// this.subscribedVideos.set(event.id, video);
|
if (video.created_at > prevActive.created_at) {
|
||||||
// onVideo(video) to re-render, etc.
|
this.activeMap.set(activeKey, video);
|
||||||
|
onVideo(video);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.error("[subscribeVideos] Error parsing event:", err);
|
console.error("[subscribeVideos] Error processing event:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -542,61 +537,60 @@ class NostrClient {
|
|||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.log("[subscribeVideos] Reached EOSE for all relays");
|
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.
|
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
|
||||||
* (Limit has been reduced to 300 for better performance.)
|
|
||||||
*/
|
*/
|
||||||
async fetchVideos() {
|
async fetchVideos() {
|
||||||
const filter = {
|
const filter = {
|
||||||
kinds: [30078],
|
kinds: [30078],
|
||||||
"#t": ["video"],
|
"#t": ["video"],
|
||||||
limit: 300, // Reduced from 1000 for quicker fetches
|
limit: 300,
|
||||||
since: 0,
|
since: 0,
|
||||||
};
|
};
|
||||||
const videoEvents = new Map();
|
|
||||||
|
|
||||||
|
const localAll = new Map();
|
||||||
try {
|
try {
|
||||||
// Query each relay in parallel
|
// 1) Fetch all events from each relay
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
this.relays.map(async (url) => {
|
this.relays.map(async (url) => {
|
||||||
const events = await this.pool.list([url], [filter]);
|
const events = await this.pool.list([url], [filter]);
|
||||||
for (const evt of events) {
|
for (const evt of events) {
|
||||||
try {
|
const vid = convertEventToVideo(evt);
|
||||||
const content = JSON.parse(evt.content);
|
localAll.set(evt.id, vid);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Turn the Map into a sorted array
|
// 2) Merge into this.allEvents
|
||||||
const allVideos = Array.from(videoEvents.values()).sort(
|
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
|
(a, b) => b.created_at - a.created_at
|
||||||
);
|
);
|
||||||
return allVideos;
|
return activeVideos;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("fetchVideos error:", err);
|
console.error("fetchVideos error:", err);
|
||||||
return [];
|
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) {
|
async getEventById(eventId) {
|
||||||
|
const local = this.allEvents.get(eventId);
|
||||||
|
if (local) {
|
||||||
|
return local;
|
||||||
|
}
|
||||||
|
// direct fetch if missing
|
||||||
try {
|
try {
|
||||||
const isValid =
|
for (const url of this.relays) {
|
||||||
content &&
|
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
|
||||||
typeof content === "object" &&
|
if (maybeEvt && maybeEvt.id === eventId) {
|
||||||
typeof content.title === "string" &&
|
const video = convertEventToVideo(maybeEvt);
|
||||||
content.title.length > 0 &&
|
this.allEvents.set(eventId, video);
|
||||||
typeof content.magnet === "string" &&
|
return video;
|
||||||
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 (err) {
|
||||||
} catch (error) {
|
|
||||||
if (isDevMode) {
|
if (isDevMode) {
|
||||||
console.error("Error validating video:", error);
|
console.error("getEventById direct fetch error:", err);
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
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
|
// js/webtorrent.js
|
||||||
|
|
||||||
import WebTorrent from "./webtorrent.min.js";
|
import WebTorrent from "./webtorrent.min.js";
|
||||||
|
|
||||||
export class TorrentClient {
|
export class TorrentClient {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Create WebTorrent client
|
|
||||||
this.client = new WebTorrent();
|
this.client = new WebTorrent();
|
||||||
this.currentTorrent = null;
|
this.currentTorrent = null;
|
||||||
this.TIMEOUT_DURATION = 60000; // 60 seconds
|
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) {
|
log(msg) {
|
||||||
@@ -62,9 +60,6 @@ export class TorrentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers the service worker, waiting until it's fully active before proceeding.
|
|
||||||
*/
|
|
||||||
async setupServiceWorker() {
|
async setupServiceWorker() {
|
||||||
try {
|
try {
|
||||||
const isBraveBrowser = await this.isBrave();
|
const isBraveBrowser = await this.isBrave();
|
||||||
@@ -72,21 +67,18 @@ export class TorrentClient {
|
|||||||
if (!window.isSecureContext) {
|
if (!window.isSecureContext) {
|
||||||
throw new Error("HTTPS or localhost required");
|
throw new Error("HTTPS or localhost required");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
||||||
throw new Error("Service Worker not supported or disabled");
|
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) {
|
if (isBraveBrowser) {
|
||||||
this.log("Checking Brave configuration...");
|
this.log("Checking Brave configuration...");
|
||||||
|
|
||||||
if (!navigator.serviceWorker) {
|
if (!navigator.serviceWorker) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"Please enable Service Workers in Brave Shield settings"
|
"Please enable Service Workers in Brave Shield settings"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
throw new Error("Please enable WebRTC in Brave Shield settings");
|
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);
|
await this.waitForServiceWorkerActivation(registration);
|
||||||
this.log("Service worker activated");
|
this.log("Service worker activated");
|
||||||
|
|
||||||
// Make sure it’s truly active
|
|
||||||
const readyRegistration = await Promise.race([
|
const readyRegistration = await Promise.race([
|
||||||
navigator.serviceWorker.ready,
|
navigator.serviceWorker.ready,
|
||||||
new Promise((_, reject) =>
|
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) {
|
async streamVideo(magnetURI, videoElement) {
|
||||||
try {
|
try {
|
||||||
@@ -180,314 +171,127 @@ export class TorrentClient {
|
|||||||
throw new Error("Service worker setup failed");
|
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.client.createServer({ controller: registration });
|
||||||
this.log("WebTorrent server created");
|
this.log("WebTorrent server created");
|
||||||
|
|
||||||
const isFirefoxBrowser = this.isFirefox();
|
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.log("Starting torrent download (Firefox path)");
|
||||||
this.client.add(
|
this.client.add(
|
||||||
magnetURI,
|
magnetURI,
|
||||||
{
|
{ strategy: "sequential", maxWebConns: 4 },
|
||||||
strategy: "sequential",
|
|
||||||
maxWebConns: 4, // reduce concurrency
|
|
||||||
},
|
|
||||||
(torrent) => {
|
(torrent) => {
|
||||||
|
this.log("Torrent added (Firefox path):", torrent.name);
|
||||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// ----------------------
|
|
||||||
// CHROME / OTHER BROWSERS CODE PATH
|
|
||||||
// (your original "faster" approach)
|
|
||||||
// ----------------------
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.log("Starting torrent download (Chrome path)");
|
this.log("Starting torrent download (Chrome path)");
|
||||||
this.client.add(magnetURI, (torrent) => {
|
this.client.add(magnetURI, (torrent) => {
|
||||||
|
this.log("Torrent added (Chrome path):", torrent.name);
|
||||||
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.log("Failed to setup video streaming:", error);
|
this.log("Failed to setup video streaming:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Minimal handleChromeTorrent — no internal setInterval
|
||||||
* The "faster" original approach for Chrome/other browsers.
|
|
||||||
*/
|
|
||||||
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
||||||
this.log("Torrent added (Chrome path): " + torrent.name);
|
const file = torrent.files.find((f) =>
|
||||||
|
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
const error = new Error("No compatible video file found in torrent");
|
return reject(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mute for autoplay
|
// Mute & crossOrigin
|
||||||
videoElement.muted = true;
|
videoElement.muted = true;
|
||||||
videoElement.crossOrigin = "anonymous";
|
videoElement.crossOrigin = "anonymous";
|
||||||
|
|
||||||
// Error handling same as old code
|
// Catch video errors
|
||||||
videoElement.addEventListener("error", (e) => {
|
videoElement.addEventListener("error", (e) => {
|
||||||
const errObj = e.target.error;
|
this.log("Video error:", 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.";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Attempt autoplay
|
// Attempt autoplay
|
||||||
videoElement.addEventListener("canplay", () => {
|
videoElement.addEventListener("canplay", () => {
|
||||||
const playPromise = videoElement.play();
|
videoElement.play().catch((err) => {
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
|
||||||
.then(() => this.log("Autoplay started (Chrome path)"))
|
|
||||||
.catch((err) => {
|
|
||||||
this.log("Autoplay failed:", 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.addEventListener("loadedmetadata", () => {
|
// Actually stream
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
file.streamTo(videoElement); // no chunk constraints
|
file.streamTo(videoElement);
|
||||||
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);
|
|
||||||
|
|
||||||
this.currentTorrent = torrent;
|
this.currentTorrent = torrent;
|
||||||
resolve();
|
resolve(torrent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("Streaming error (Chrome path):", err);
|
this.log("Streaming error (Chrome path):", err);
|
||||||
if (status) status.textContent = "Error starting video stream";
|
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Torrent error event
|
// Also handle torrent error events
|
||||||
torrent.on("error", (err) => {
|
torrent.on("error", (err) => {
|
||||||
this.log("Torrent error (Chrome path):", err);
|
this.log("Torrent error (Chrome path):", err);
|
||||||
if (status) status.textContent = "Error loading video";
|
|
||||||
clearInterval(this.statsInterval);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Minimal handleFirefoxTorrent
|
||||||
* The new approach for Firefox: sequential, concurrency limit, smaller chunk size.
|
|
||||||
*/
|
|
||||||
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
|
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
|
||||||
this.log("Torrent added (Firefox path): " + torrent.name);
|
const file = torrent.files.find((f) =>
|
||||||
|
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||||
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")
|
|
||||||
);
|
);
|
||||||
if (!file) {
|
if (!file) {
|
||||||
const error = new Error("No compatible video file found in torrent");
|
return reject(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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
videoElement.muted = true;
|
videoElement.muted = true;
|
||||||
videoElement.crossOrigin = "anonymous";
|
videoElement.crossOrigin = "anonymous";
|
||||||
|
|
||||||
videoElement.addEventListener("error", (e) => {
|
videoElement.addEventListener("error", (e) => {
|
||||||
const errObj = e.target.error;
|
this.log("Video error (Firefox path):", 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.";
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
videoElement.addEventListener("canplay", () => {
|
videoElement.addEventListener("canplay", () => {
|
||||||
const playPromise = videoElement.play();
|
videoElement.play().catch((err) => {
|
||||||
if (playPromise !== undefined) {
|
|
||||||
playPromise
|
|
||||||
.then(() => this.log("Autoplay started (Firefox path)"))
|
|
||||||
.catch((err) => {
|
|
||||||
this.log("Autoplay failed:", 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.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 {
|
try {
|
||||||
file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); // 32 KB chunk
|
file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
|
||||||
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);
|
|
||||||
|
|
||||||
this.currentTorrent = torrent;
|
this.currentTorrent = torrent;
|
||||||
resolve();
|
resolve(torrent);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.log("Streaming error (Firefox path):", err);
|
this.log("Streaming error (Firefox path):", err);
|
||||||
if (status) status.textContent = "Error starting video stream";
|
|
||||||
reject(err);
|
reject(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for torrent errors
|
|
||||||
torrent.on("error", (err) => {
|
torrent.on("error", (err) => {
|
||||||
this.log("Torrent error (Firefox path):", err);
|
this.log("Torrent error (Firefox path):", err);
|
||||||
if (status) status.textContent = "Error loading video";
|
|
||||||
clearInterval(this.statsInterval);
|
|
||||||
reject(err);
|
reject(err);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clean up after playback or page unload.
|
* Clean up
|
||||||
*/
|
*/
|
||||||
async cleanup() {
|
async cleanup() {
|
||||||
try {
|
try {
|
||||||
if (this.statsInterval) {
|
// No local interval to clear here
|
||||||
clearInterval(this.statsInterval);
|
|
||||||
}
|
|
||||||
if (this.currentTorrent) {
|
if (this.currentTorrent) {
|
||||||
this.currentTorrent.destroy();
|
this.currentTorrent.destroy();
|
||||||
}
|
}
|
||||||
if (this.client) {
|
if (this.client) {
|
||||||
await this.client.destroy();
|
await this.client.destroy();
|
||||||
// Recreate fresh client for next time
|
|
||||||
this.client = new WebTorrent();
|
this.client = new WebTorrent();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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