mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-08 06:58:43 +00:00
big update
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>
|
@@ -229,7 +229,11 @@ textarea:focus {
|
||||
ring: 2px var(--color-primary);
|
||||
}
|
||||
|
||||
/* Global button styles -- only apply to normal (non-icon) buttons */
|
||||
/* -------------------------------------------
|
||||
COMMENTED OUT the 'button:not(.icon-button)'
|
||||
global rule that overrides your circles
|
||||
--------------------------------------------
|
||||
|
||||
button:not(.icon-button) {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: var(--color-primary);
|
||||
@@ -248,6 +252,7 @@ button:not(.icon-button):focus {
|
||||
outline: none;
|
||||
ring: 2px var(--color-primary);
|
||||
}
|
||||
*/
|
||||
|
||||
/* Utility Classes */
|
||||
.line-clamp-2 {
|
||||
@@ -425,18 +430,13 @@ footer a:hover {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* --- New Classes for Icon Buttons & Images --- */
|
||||
|
||||
/* Circular icon buttons */
|
||||
/* Circular Icon Buttons */
|
||||
.icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
/* Fixed width/height for a perfect circle */
|
||||
width: 2.5rem; /* 40px */
|
||||
height: 2.5rem; /* 40px */
|
||||
|
||||
line-height: 0;
|
||||
background-color: #3f3f46; /* Gray 700 */
|
||||
color: #fff;
|
||||
@@ -446,40 +446,28 @@ footer a:hover {
|
||||
transition: background-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
/* Hover state: slightly lighter gray */
|
||||
.icon-button:hover {
|
||||
background-color: #52525b; /* Gray 600 */
|
||||
}
|
||||
|
||||
/* Focus/active states: red ring */
|
||||
.icon-button:focus,
|
||||
.icon-button:active {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6); /* Red ring #dc2626 */
|
||||
box-shadow: 0 0 0 3px rgba(220, 38, 38, 0.6);
|
||||
}
|
||||
|
||||
/* Icon images (force white if originally black) */
|
||||
.icon-image {
|
||||
width: 1.25rem; /* 20px */
|
||||
height: 1.25rem; /* 20px */
|
||||
|
||||
/*
|
||||
If your icon is black and you want to invert it to white, use this:
|
||||
filter: brightness(0) invert(1);
|
||||
|
||||
If your icon is already white, keep it commented out or remove it.
|
||||
*/
|
||||
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Create a container that preserves a 16:9 aspect ratio via padding-top. */
|
||||
/* Force 16:9 ratio using padding-top technique */
|
||||
/* Ratio 16:9 Container */
|
||||
.ratio-16-9 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-top: 56.25%; /* 16:9 => 9/16 = 0.5625 => 56.25% */
|
||||
background-color: #1e293b; /* fallback background if image doesn't load */
|
||||
padding-top: 56.25%;
|
||||
background-color: #1e293b;
|
||||
}
|
||||
|
||||
.ratio-16-9 > img {
|
||||
|
1
src/css/tailwind.min.css
vendored
Normal file
1
src/css/tailwind.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
267
src/index.html
267
src/index.html
@@ -1,60 +1,43 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Decentralized Video Sharing</title>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Decentralized Video Sharing</title>
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta
|
||||
property="og:title"
|
||||
content="BitVid - Decentralized Video Sharing"
|
||||
/>
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Share videos and follow creators freely, in a truly decentralized way."
|
||||
/>
|
||||
<meta
|
||||
property="og:image"
|
||||
content="https://bitvid.netlify.app/assets/jpg/bitvid.jpg"
|
||||
/>
|
||||
<meta property="og:url" content="https://bitvid.network" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="BitVid - Decentralized Video Sharing" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Share videos and follow creators freely, in a truly decentralized way."
|
||||
/>
|
||||
<meta property="og:image" content="assets/jpg/bitvid.jpg" />
|
||||
<meta property="og:url" content="https://bitvid.network" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:locale" content="en_US" />
|
||||
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
||||
<link
|
||||
rel="apple-touch-icon"
|
||||
sizes="180x180"
|
||||
href="/apple-touch-icon.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="assets/png/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="assets/png/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
<!-- App Icons -->
|
||||
<link rel="icon" href="assets/favicon.ico" sizes="any" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="32x32"
|
||||
href="assets/png/favicon-32x32.png"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
sizes="16x16"
|
||||
href="assets/png/favicon-16x16.png"
|
||||
/>
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<meta name="theme-color" content="#0f172a" />
|
||||
|
||||
<!-- Styles -->
|
||||
<link
|
||||
href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<!-- Rest of your page content -->
|
||||
</body>
|
||||
</html>
|
||||
<!-- Styles -->
|
||||
<link href="css/tailwind.min.css" rel="stylesheet" />
|
||||
<link href="css/style.css" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-100">
|
||||
<div
|
||||
@@ -74,24 +57,51 @@
|
||||
|
||||
<!-- Login Section -->
|
||||
<div id="loginSection" class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<!-- Login Button -->
|
||||
<button
|
||||
id="loginButton"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-blue-500 text-white text-sm font-bold leading-none whitespace-nowrap appearance-none hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Login with Nostr
|
||||
login
|
||||
</button>
|
||||
<p id="userStatus" class="mt-4 text-gray-500 hidden">
|
||||
Logged in as: <span id="userPubKey"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
<!-- (Old) Logout Button is REMOVED or commented out -->
|
||||
<!-- <button
|
||||
id="logoutButton"
|
||||
...
|
||||
>logout</button>
|
||||
-->
|
||||
|
||||
<!-- Upload Button (hidden by default) -->
|
||||
<button
|
||||
id="logoutButton"
|
||||
class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 hidden"
|
||||
id="uploadButton"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-green-500 text-white text-xl font-bold leading-none whitespace-nowrap appearance-none hover:bg-green-600 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 hidden"
|
||||
>
|
||||
Logout
|
||||
+
|
||||
</button>
|
||||
|
||||
<!-- NEW: Profile Button (hidden by default) -->
|
||||
<button
|
||||
id="profileButton"
|
||||
class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-gray-600 text-white text-sm leading-none hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 hidden"
|
||||
>
|
||||
<!-- We'll dynamically load the user's avatar into this <img> once logged in -->
|
||||
<img
|
||||
id="profileAvatar"
|
||||
src="assets/jpg/default-profile.jpg"
|
||||
alt="Profile"
|
||||
class="object-cover rounded-full w-full h-full"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- (Optional) user status is hidden or removed -->
|
||||
<div>
|
||||
<!-- We can comment out or hide this in CSS if you do not want to show the hex key: -->
|
||||
<!-- <p id="userStatus" class="mt-4 text-gray-500 hidden">
|
||||
Logged in as: <span id="userPubKey"></span>
|
||||
</p> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -111,122 +121,15 @@
|
||||
<!-- Success messages will appear here -->
|
||||
</div>
|
||||
|
||||
<!-- Video Submission Form -->
|
||||
<div
|
||||
class="bg-white p-6 rounded-lg shadow-md mb-8 hidden"
|
||||
id="videoFormContainer"
|
||||
>
|
||||
<h2 class="text-xl font-semibold mb-4">Share a Video</h2>
|
||||
<form id="submitForm" class="space-y-4">
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-gray-700"
|
||||
>Title</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<!-- The main container for dynamic views -->
|
||||
<main id="viewContainer" class="flex-grow mb-8">
|
||||
<!-- We'll load our "most-recent-videos.html" or other views here -->
|
||||
</main>
|
||||
|
||||
<div>
|
||||
<label for="magnet" class="block text-sm font-medium text-gray-700"
|
||||
>Magnet Link</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="magnet"
|
||||
required
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
for="thumbnail"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Thumbnail URL (optional)</label
|
||||
>
|
||||
<input
|
||||
type="url"
|
||||
id="thumbnail"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description Field -->
|
||||
<div>
|
||||
<label
|
||||
for="description"
|
||||
class="block text-sm font-medium text-gray-700"
|
||||
>Description (optional)</label
|
||||
>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="3"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- ADDED FOR PRIVATE LISTINGS -->
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="isPrivate"
|
||||
class="form-checkbox h-5 w-5"
|
||||
/>
|
||||
<span class="text-sm font-medium text-gray-700"
|
||||
>Private Listing (Encrypt Magnet)</span
|
||||
>
|
||||
</div>
|
||||
<!-- END ADDED FOR PRIVATE LISTINGS -->
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
|
||||
>
|
||||
Share Video
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Video Player Section -->
|
||||
<div id="playerSection" class="mb-8 hidden">
|
||||
<video id="video" controls class="w-full rounded-lg shadow-md"></video>
|
||||
<!-- Status and Stats -->
|
||||
<div class="mt-4">
|
||||
<div id="status" class="text-gray-700 mb-2">
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
<div class="w-full bg-gray-300 rounded-full h-2 mb-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full"
|
||||
id="progress"
|
||||
style="width: 0%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm text-gray-600">
|
||||
<span id="peers">Peers: 0</span>
|
||||
<span id="speed">0 KB/s</span>
|
||||
<span id="downloaded">0 MB / 0 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video List -->
|
||||
<div class="mb-8">
|
||||
<div
|
||||
id="videoList"
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||
>
|
||||
<!-- Videos will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Imported Video Player Modal -->
|
||||
<!-- Imported Video Player Modal (goes into modalContainer) -->
|
||||
<div id="modalContainer"></div>
|
||||
|
||||
<!-- Tagline / Slogan -->
|
||||
<div class="text-center mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-500 tracking-wide">
|
||||
seed. zap. subscribe.
|
||||
@@ -440,17 +343,17 @@
|
||||
</footer>
|
||||
|
||||
<!-- Scripts -->
|
||||
<!-- Load WebTorrent via CDN -->
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/webtorrent/1.9.7/webtorrent.min.js"></script> -->
|
||||
<!-- Load Nostr library -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<!-- Load JavaScript Modules -->
|
||||
<script src="js/libs/nostr.bundle.js"></script>
|
||||
<script type="module" src="js/config.js"></script>
|
||||
<script type="module" src="js/lists.js"></script>
|
||||
<script type="module" src="js/accessControl.js"></script>
|
||||
<script type="module" src="js/webtorrent.js"></script>
|
||||
<script type="module" src="js/nostr.js"></script>
|
||||
|
||||
<!-- Optional: a separate manager for view loading -->
|
||||
<script type="module" src="js/viewManager.js"></script>
|
||||
|
||||
<!-- Main app script -->
|
||||
<script type="module" src="js/app.js"></script>
|
||||
</div>
|
||||
</body>
|
||||
|
1359
src/js/app copy.js
Normal file
1359
src/js/app copy.js
Normal file
File diff suppressed because it is too large
Load Diff
1191
src/js/app.js
1191
src/js/app.js
File diff suppressed because it is too large
Load Diff
608
src/js/nostr copy 2.js
Normal file
608
src/js/nostr copy 2.js
Normal file
@@ -0,0 +1,608 @@
|
||||
// js/nostr.js
|
||||
|
||||
import { isDevMode } from "./config.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
|
||||
const RELAY_URLS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Just a helper to keep error spam in check
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
function logErrorOnce(message, eventContent = null) {
|
||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
||||
console.error(message);
|
||||
if (eventContent) {
|
||||
console.log(`Event Content: ${eventContent}`);
|
||||
}
|
||||
errorLogCount++;
|
||||
}
|
||||
if (errorLogCount === MAX_ERROR_LOGS) {
|
||||
console.error(
|
||||
"Maximum error log limit reached. Further errors will be suppressed."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example "encryption" that just reverses strings.
|
||||
* In real usage, swap with actual crypto.
|
||||
*/
|
||||
function fakeEncrypt(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
}
|
||||
function fakeDecrypt(encrypted) {
|
||||
return encrypted.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Nostr event => your "video" object.
|
||||
*/
|
||||
function convertEventToVideo(event) {
|
||||
const content = JSON.parse(event.content || "{}");
|
||||
return {
|
||||
id: event.id,
|
||||
// We store a 'videoRootId' in content so we can group multiple edits
|
||||
videoRootId: content.videoRootId || null,
|
||||
version: content.version ?? 1,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
deleted: content.deleted === true,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key each "active" video by its root ID => so you only store
|
||||
* the newest version for each root. But for older events w/o videoRootId,
|
||||
* or w/o 'd' tag, we handle fallback logic below.
|
||||
*/
|
||||
function getActiveKey(video) {
|
||||
// If it has a videoRootId, we use that
|
||||
if (video.videoRootId) {
|
||||
return `ROOT:${video.videoRootId}`;
|
||||
}
|
||||
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
|
||||
// This is a fallback approach so older events appear in the "active map".
|
||||
const dTag = video.tags?.find((t) => t[0] === "d");
|
||||
if (dTag) {
|
||||
return `${video.pubkey}:${dTag[1]}`;
|
||||
}
|
||||
return `LEGACY:${video.id}`;
|
||||
}
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.pubkey = null;
|
||||
this.relays = RELAY_URLS;
|
||||
|
||||
// All events—old or new—so older share links still work
|
||||
this.allEvents = new Map();
|
||||
|
||||
// "activeMap" holds only the newest version for each root ID (or fallback).
|
||||
this.activeMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
async init() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
|
||||
try {
|
||||
this.pool = new window.NostrTools.SimplePool();
|
||||
const results = await this.connectToRelays();
|
||||
const successfulRelays = results
|
||||
.filter((r) => r.success)
|
||||
.map((r) => r.url);
|
||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
||||
if (isDevMode) {
|
||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Nostr init failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async connectToRelays() {
|
||||
return Promise.all(
|
||||
this.relays.map(
|
||||
(url) =>
|
||||
new Promise((resolve) => {
|
||||
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsub();
|
||||
resolve({ url, success: false });
|
||||
}, 5000);
|
||||
|
||||
const succeed = () => {
|
||||
clearTimeout(timeout);
|
||||
sub.unsub();
|
||||
resolve({ url, success: true });
|
||||
};
|
||||
|
||||
sub.on("event", succeed);
|
||||
sub.on("eose", succeed);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt Nostr extension login or abort
|
||||
*/
|
||||
async login() {
|
||||
try {
|
||||
if (!window.nostr) {
|
||||
console.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||
);
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Got pubkey:", pubkey);
|
||||
console.log("Converted to npub:", npub);
|
||||
console.log("Whitelist:", accessControl.getWhitelist());
|
||||
console.log("Blacklist:", accessControl.getBlacklist());
|
||||
}
|
||||
|
||||
// Access control check
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
} else {
|
||||
throw new Error("Access restricted to whitelisted users only.");
|
||||
}
|
||||
}
|
||||
|
||||
this.pubkey = pubkey;
|
||||
if (isDevMode) {
|
||||
console.log("Logged in with extension. Pubkey:", this.pubkey);
|
||||
}
|
||||
return this.pubkey;
|
||||
} catch (e) {
|
||||
console.error("Login error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pubkey = null;
|
||||
if (isDevMode) console.log("User logged out.");
|
||||
}
|
||||
|
||||
decodeNsec(nsec) {
|
||||
try {
|
||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error("Invalid NSEC key.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
|
||||
*/
|
||||
async publishVideo(videoData, pubkey) {
|
||||
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Publishing new video with data:", videoData);
|
||||
}
|
||||
|
||||
let finalMagnet = videoData.magnet;
|
||||
if (videoData.isPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalMagnet);
|
||||
}
|
||||
|
||||
// new "videoRootId" ensures all future edits know they're from the same root
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const contentObject = {
|
||||
videoRootId,
|
||||
version: videoData.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: videoData.isPrivate ?? false,
|
||||
title: videoData.title || "",
|
||||
magnet: finalMagnet,
|
||||
thumbnail: videoData.thumbnail || "",
|
||||
description: videoData.description || "",
|
||||
mode: videoData.mode || "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", dTagValue],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Publish event with brand-new root:", videoRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) console.log("Signed event:", signedEvent);
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) console.log(`Video published to ${url}`);
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits a video by creating a *new event* with a brand-new d tag,
|
||||
* but reuses the same videoRootId as the original.
|
||||
* => old link remains pinned to the old event, new link is a fresh ID.
|
||||
*/
|
||||
async editVideo(originalVideo, updatedData, pubkey) {
|
||||
if (!pubkey) throw new Error("Not logged in to edit.");
|
||||
if (originalVideo.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this video (different pubkey).");
|
||||
}
|
||||
|
||||
// Use the videoRootId from the originalVideo directly
|
||||
const rootId = originalVideo.videoRootId || null;
|
||||
|
||||
// Determine if the original magnet was encrypted and decrypt if necessary
|
||||
let oldPlainMagnet = originalVideo.magnet || "";
|
||||
if (originalVideo.isPrivate && oldPlainMagnet) {
|
||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||
}
|
||||
|
||||
// Determine new privacy setting and magnet
|
||||
const wantPrivate =
|
||||
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
|
||||
let finalPlainMagnet = (updatedData.magnet || "").trim();
|
||||
if (!finalPlainMagnet) {
|
||||
finalPlainMagnet = oldPlainMagnet; // Fallback to original if not provided
|
||||
}
|
||||
|
||||
let finalMagnet = finalPlainMagnet;
|
||||
if (wantPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
||||
}
|
||||
|
||||
// Use existing rootId or generate a new one (if original lacked it)
|
||||
const newRootId =
|
||||
rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Construct the new content object using originalVideo's properties where applicable
|
||||
const contentObject = {
|
||||
videoRootId: newRootId,
|
||||
version: updatedData.version ?? originalVideo.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: wantPrivate,
|
||||
title: updatedData.title ?? originalVideo.title,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
|
||||
description: updatedData.description ?? originalVideo.description,
|
||||
mode: updatedData.mode ?? originalVideo.mode ?? "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", newD], // New dTag for the edit
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Creating edited event with root ID:", newRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error(`Publish failed to ${url}`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
console.error("Edit failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
|
||||
*/
|
||||
async deleteVideo(originalEvent, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("Not logged in to delete.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("Not your event (pubkey mismatch).");
|
||||
}
|
||||
|
||||
// If front-end didn't pass the tags array, load the full event from local or from the relay:
|
||||
let baseEvent = originalEvent;
|
||||
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
|
||||
const fetched = await this.getEventById(originalEvent.id);
|
||||
if (!fetched) {
|
||||
throw new Error("Could not fetch the original event for deletion.");
|
||||
}
|
||||
// Rebuild baseEvent as a raw Nostr event that includes .tags and .content
|
||||
baseEvent = {
|
||||
id: fetched.id,
|
||||
pubkey: fetched.pubkey,
|
||||
// put the raw JSON content back into string form:
|
||||
content: JSON.stringify({
|
||||
version: fetched.version,
|
||||
deleted: fetched.deleted,
|
||||
isPrivate: fetched.isPrivate,
|
||||
title: fetched.title,
|
||||
magnet: fetched.magnet,
|
||||
thumbnail: fetched.thumbnail,
|
||||
description: fetched.description,
|
||||
mode: fetched.mode,
|
||||
}),
|
||||
tags: fetched.tags,
|
||||
};
|
||||
}
|
||||
|
||||
// Now try to get the old d-tag
|
||||
const dTag = baseEvent.tags.find((t) => t[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
// Parse out the old content so we can preserve version, isPrivate, etc.
|
||||
const oldContent = JSON.parse(baseEvent.content || "{}");
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
// Mark it "deleted" and clear out magnet, thumbnail, etc.
|
||||
const contentObject = {
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
title: oldContent.title || "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "Video was deleted by creator.",
|
||||
mode: oldContent.mode || "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
// We reuse the same d => overshadow the original event
|
||||
["d", existingD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Deleting video => mark 'deleted:true'.", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed deleted event:", signedEvent);
|
||||
}
|
||||
|
||||
// Publish everywhere
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`Delete event published to ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Failed to publish deleted event to ${url}:`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
return signedEvent;
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign deleted event:", err);
|
||||
}
|
||||
throw new Error("Failed to sign deleted event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to *all* video events. We store them in this.allEvents so older
|
||||
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
|
||||
* version of each root (or fallback).
|
||||
*/
|
||||
subscribeVideos(onVideo) {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 500,
|
||||
since: 0,
|
||||
};
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||
}
|
||||
|
||||
const sub = this.pool.sub(this.relays, [filter]);
|
||||
sub.on("event", (event) => {
|
||||
try {
|
||||
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
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
if (existing && existing.id === video.id) {
|
||||
this.activeMap.delete(activeKey);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not deleted => see if it’s the newest
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
if (!prevActive) {
|
||||
// brand new => set it
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
} else {
|
||||
// compare timestamps
|
||||
if (video.created_at > prevActive.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sub.on("eose", () => {
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Reached EOSE for all relays");
|
||||
}
|
||||
});
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
|
||||
*/
|
||||
async fetchVideos() {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 300,
|
||||
since: 0,
|
||||
};
|
||||
|
||||
const localAll = new Map();
|
||||
try {
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
const vid = convertEventToVideo(evt);
|
||||
localAll.set(evt.id, vid);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Merge into this.allEvents
|
||||
for (const [id, vid] of localAll.entries()) {
|
||||
this.allEvents.set(id, vid);
|
||||
}
|
||||
|
||||
// Rebuild activeMap
|
||||
this.activeMap.clear();
|
||||
for (const [id, video] of this.allEvents.entries()) {
|
||||
if (video.deleted) continue;
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
}
|
||||
}
|
||||
|
||||
// Return an array of newest for each root
|
||||
const activeVideos = Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return activeVideos;
|
||||
} catch (err) {
|
||||
console.error("fetchVideos error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to fetch an event by ID from local cache, then from the relays
|
||||
*/
|
||||
async getEventById(eventId) {
|
||||
const local = this.allEvents.get(eventId);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
// direct fetch if missing
|
||||
try {
|
||||
for (const url of this.relays) {
|
||||
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
|
||||
if (maybeEvt && maybeEvt.id === eventId) {
|
||||
const video = convertEventToVideo(maybeEvt);
|
||||
this.allEvents.set(eventId, video);
|
||||
return video;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("getEventById direct fetch error:", err);
|
||||
}
|
||||
}
|
||||
return null; // not found
|
||||
}
|
||||
|
||||
/**
|
||||
* Return newest versions from activeMap if you want to skip older events
|
||||
*/
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const nostrClient = new NostrClient();
|
684
src/js/nostr copy.js
Normal file
684
src/js/nostr copy.js
Normal file
@@ -0,0 +1,684 @@
|
||||
// js/nostr.js
|
||||
|
||||
import { isDevMode } from "./config.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
|
||||
const RELAY_URLS = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://nos.lol",
|
||||
"wss://relay.snort.social",
|
||||
"wss://nostr.wine",
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Rate limiting for error logs
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
||||
|
||||
function logErrorOnce(message, eventContent = null) {
|
||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
||||
console.error(message);
|
||||
if (eventContent) {
|
||||
console.log(`Event Content: ${eventContent}`);
|
||||
}
|
||||
errorLogCount++;
|
||||
}
|
||||
if (errorLogCount === MAX_ERROR_LOGS) {
|
||||
console.error(
|
||||
"Maximum error log limit reached. Further errors will be suppressed."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A very naive "encryption" function that just reverses the string.
|
||||
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
||||
*/
|
||||
function fakeEncrypt(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
}
|
||||
|
||||
function fakeDecrypt(encrypted) {
|
||||
return encrypted.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Nostr event into your "video" object.
|
||||
*/
|
||||
function convertEventToVideo(event) {
|
||||
const content = JSON.parse(event.content || "{}");
|
||||
return {
|
||||
id: event.id,
|
||||
version: content.version ?? 1,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
deleted: content.deleted === true,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a combined key for (pubkey, dTagValue).
|
||||
* If there's no `d` tag, we fallback to a special key so
|
||||
* those older events still appear in the grid.
|
||||
*/
|
||||
function getPubkeyDKey(evt) {
|
||||
const dTag = evt.tags.find((t) => t[0] === "d");
|
||||
if (dTag) {
|
||||
return `${evt.pubkey}:${dTag[1]}`;
|
||||
} else {
|
||||
// NEW: older events didn't have a d-tag, so use an alternative key
|
||||
// Example: "npubXYZ:no-d:id-of-event"
|
||||
return `${evt.pubkey}:no-d:${evt.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.pubkey = null;
|
||||
this.relays = RELAY_URLS;
|
||||
|
||||
// All events, old or new, keyed by event.id
|
||||
this.allEvents = new Map();
|
||||
|
||||
// Only the "active" (non-deleted) newest version per (pubkey + dTag OR fallback)
|
||||
this.activeMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Nostr client by connecting to relays.
|
||||
*/
|
||||
async init() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
|
||||
try {
|
||||
this.pool = new window.NostrTools.SimplePool();
|
||||
const results = await this.connectToRelays();
|
||||
const successfulRelays = results
|
||||
.filter((r) => r.success)
|
||||
.map((r) => r.url);
|
||||
|
||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
||||
if (isDevMode) {
|
||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Nostr init failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Connects to each relay, ensuring they're alive
|
||||
async connectToRelays() {
|
||||
return Promise.all(
|
||||
this.relays.map(
|
||||
(url) =>
|
||||
new Promise((resolve) => {
|
||||
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
|
||||
const timeout = setTimeout(() => {
|
||||
sub.unsub();
|
||||
resolve({ url, success: false });
|
||||
}, 5000);
|
||||
|
||||
const succeed = () => {
|
||||
clearTimeout(timeout);
|
||||
sub.unsub();
|
||||
resolve({ url, success: true });
|
||||
};
|
||||
|
||||
sub.on("event", succeed);
|
||||
sub.on("eose", succeed);
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user using a Nostr extension or by entering an NSEC key.
|
||||
*/
|
||||
async login() {
|
||||
try {
|
||||
if (!window.nostr) {
|
||||
console.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (like Alby or nos2x)."
|
||||
);
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Got pubkey:", pubkey);
|
||||
console.log("Converted to npub:", npub);
|
||||
console.log("Whitelist:", accessControl.getWhitelist());
|
||||
console.log("Blacklist:", accessControl.getBlacklist());
|
||||
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
|
||||
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
|
||||
}
|
||||
|
||||
// Check access control
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error(
|
||||
"Your account has been blocked from accessing this platform."
|
||||
);
|
||||
} else {
|
||||
throw new Error(
|
||||
"Access is currently restricted to whitelisted users only."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
this.pubkey = pubkey;
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
"Successfully logged in with extension. Public key:",
|
||||
this.pubkey
|
||||
);
|
||||
}
|
||||
return this.pubkey;
|
||||
} catch (e) {
|
||||
console.error("Login error:", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.pubkey = null;
|
||||
if (isDevMode) console.log("User logged out.");
|
||||
}
|
||||
|
||||
decodeNsec(nsec) {
|
||||
try {
|
||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
||||
return data;
|
||||
} catch (error) {
|
||||
throw new Error("Invalid NSEC key.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new video event to all relays (creates a brand-new note).
|
||||
*/
|
||||
async publishVideo(videoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Publishing video with data:", videoData);
|
||||
}
|
||||
|
||||
// If user sets "isPrivate = true", encrypt the magnet
|
||||
let finalMagnet = videoData.magnet;
|
||||
if (videoData.isPrivate === true) {
|
||||
finalMagnet = fakeEncrypt(finalMagnet);
|
||||
}
|
||||
|
||||
const version = videoData.version ?? 1;
|
||||
const uniqueD = `${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 10)}`;
|
||||
const contentObject = {
|
||||
version,
|
||||
deleted: false,
|
||||
isPrivate: videoData.isPrivate || false,
|
||||
title: videoData.title,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: videoData.thumbnail,
|
||||
description: videoData.description,
|
||||
mode: videoData.mode,
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", uniqueD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Event content after stringify:", event.content);
|
||||
console.log("Using d tag:", uniqueD);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed event:", signedEvent);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`Event published to ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Failed to publish to ${url}:`, err.message);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing video event by reusing the same "d" tag.
|
||||
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
||||
*/
|
||||
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Editing video event:", originalEvent);
|
||||
console.log("New video data:", updatedVideoData);
|
||||
}
|
||||
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error('No "d" tag => cannot edit as addressable kind=30078.');
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
const oldDeleted = oldContent.deleted === true;
|
||||
const newVersion = updatedVideoData.version ?? oldVersion;
|
||||
|
||||
const oldWasPrivate = oldContent.isPrivate === true;
|
||||
let oldPlainMagnet = oldContent.magnet || "";
|
||||
if (oldWasPrivate && oldPlainMagnet) {
|
||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||
}
|
||||
|
||||
const newIsPrivate =
|
||||
typeof updatedVideoData.isPrivate === "boolean"
|
||||
? updatedVideoData.isPrivate
|
||||
: oldContent.isPrivate ?? false;
|
||||
|
||||
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
||||
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
||||
|
||||
let finalMagnet = finalPlainMagnet;
|
||||
if (newIsPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
||||
}
|
||||
|
||||
const contentObject = {
|
||||
version: newVersion,
|
||||
deleted: oldDeleted,
|
||||
isPrivate: newIsPrivate,
|
||||
title: updatedVideoData.title,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: updatedVideoData.thumbnail,
|
||||
description: updatedVideoData.description,
|
||||
mode: updatedVideoData.mode,
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Building updated content object:", contentObject);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", existingD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag:", existingD);
|
||||
console.log("Updated event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed edited event:", signedEvent);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Edited event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(
|
||||
`Failed to publish edited event to ${url}:`,
|
||||
err.message
|
||||
);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign edited event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign edited event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
||||
*/
|
||||
async deleteVideo(originalEvent, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Deleting video event:", originalEvent);
|
||||
}
|
||||
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
const contentObject = {
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
title: oldContent.title || "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This video has been deleted.",
|
||||
mode: oldContent.mode || "live",
|
||||
isPrivate: oldContent.isPrivate || false,
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", existingD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag for delete:", existingD);
|
||||
console.log("Deleted event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed deleted event:", signedEvent);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Deleted event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Failed to publish deleted event to ${url}:`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign deleted event:", error);
|
||||
}
|
||||
throw new Error("Failed to sign deleted event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to all video events from all relays.
|
||||
* We store them in `allEvents` (so old IDs are still available),
|
||||
* and we also maintain `activeMap` for the newest versions of each pubkey-dKey.
|
||||
*
|
||||
* @param {Function} onVideo - Callback for each newly recognized "active" video
|
||||
*/
|
||||
subscribeVideos(onVideo) {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 500,
|
||||
since: 0, // we want from the beginning
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||
}
|
||||
|
||||
const sub = this.pool.sub(this.relays, [filter]);
|
||||
|
||||
sub.on("event", (event) => {
|
||||
try {
|
||||
// Convert event => video object
|
||||
const video = convertEventToVideo(event);
|
||||
|
||||
// Always store it in allEvents
|
||||
this.allEvents.set(event.id, video);
|
||||
|
||||
// If deleted, remove from activeMap if it's the active version
|
||||
if (video.deleted) {
|
||||
const key = getPubkeyDKey(event); // might be no-d if no 'd' tag
|
||||
const activeVid = this.activeMap.get(key);
|
||||
if (activeVid && activeVid.id === event.id) {
|
||||
this.activeMap.delete(key);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// It's not deleted => see if we should set it as active
|
||||
const key = getPubkeyDKey(event); // might be "npubXYZ:no-d:ID"
|
||||
const existingActive = this.activeMap.get(key);
|
||||
if (!existingActive) {
|
||||
// brand new => store it
|
||||
this.activeMap.set(key, video);
|
||||
onVideo(video);
|
||||
} else {
|
||||
// We have an active version; check timestamps
|
||||
if (video.created_at > existingActive.created_at) {
|
||||
// It's newer => overwrite
|
||||
this.activeMap.set(key, video);
|
||||
onVideo(video);
|
||||
} else {
|
||||
// It's an older event => ignore from "active" perspective
|
||||
// but still in allEvents for old links
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error parsing event:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sub.on("eose", () => {
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Reached EOSE for all relays");
|
||||
}
|
||||
// Optionally notify that the initial load is done
|
||||
});
|
||||
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk fetch of videos from all relays. Then we build the `activeMap`
|
||||
* so your grid can show all old & new events (even if no 'd' tag).
|
||||
*/
|
||||
async fetchVideos() {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
// Increase limit if you want more than 300
|
||||
limit: 300,
|
||||
since: 0,
|
||||
};
|
||||
|
||||
const localAll = new Map();
|
||||
|
||||
try {
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
const video = convertEventToVideo(evt);
|
||||
localAll.set(evt.id, video);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Merge localAll into our global allEvents
|
||||
for (const [id, vid] of localAll.entries()) {
|
||||
this.allEvents.set(id, vid);
|
||||
}
|
||||
|
||||
// Re-build activeMap
|
||||
this.activeMap.clear();
|
||||
for (const [id, video] of this.allEvents.entries()) {
|
||||
if (video.deleted) continue; // skip
|
||||
const key = getPubkeyDKey({
|
||||
id,
|
||||
tags: video.tags,
|
||||
pubkey: video.pubkey,
|
||||
});
|
||||
const existing = this.activeMap.get(key);
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(key, video);
|
||||
}
|
||||
}
|
||||
|
||||
// Return sorted "active" array for your grid
|
||||
const activeVideos = Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return activeVideos;
|
||||
} catch (err) {
|
||||
console.error("fetchVideos error:", err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event by ID from our local cache (allEvents) if present.
|
||||
* If missing, do a direct pool.get() for that ID. This ensures older
|
||||
* "archived" events might still be loaded from the relays.
|
||||
*/
|
||||
async getEventById(eventId) {
|
||||
const local = this.allEvents.get(eventId);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
|
||||
// NEW: do a direct fetch if not found
|
||||
try {
|
||||
for (const url of this.relays) {
|
||||
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
|
||||
if (maybeEvt && maybeEvt.id === eventId) {
|
||||
const video = convertEventToVideo(maybeEvt);
|
||||
// store in allEvents
|
||||
this.allEvents.set(eventId, video);
|
||||
return video;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("getEventById direct fetch error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// not found
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the "active" videos, i.e. latest for each (pubkey+d or fallback).
|
||||
*/
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
}
|
||||
|
||||
isValidVideo(content) {
|
||||
try {
|
||||
const isValid =
|
||||
content &&
|
||||
typeof content === "object" &&
|
||||
typeof content.title === "string" &&
|
||||
content.title.length > 0 &&
|
||||
typeof content.magnet === "string" &&
|
||||
content.magnet.length > 0 &&
|
||||
typeof content.mode === "string" &&
|
||||
["dev", "live"].includes(content.mode) &&
|
||||
(typeof content.thumbnail === "string" ||
|
||||
typeof content.thumbnail === "undefined") &&
|
||||
(typeof content.description === "string" ||
|
||||
typeof content.description === "undefined");
|
||||
|
||||
if (isDevMode && !isValid) {
|
||||
console.log("Invalid video content:", content);
|
||||
}
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Error validating video:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const nostrClient = new NostrClient();
|
558
src/js/nostr.js
558
src/js/nostr.js
@@ -11,10 +11,9 @@ const RELAY_URLS = [
|
||||
"wss://relay.nostr.band",
|
||||
];
|
||||
|
||||
// Rate limiting for error logs
|
||||
// Just a helper to keep error spam in check
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100; // Adjust as needed
|
||||
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
function logErrorOnce(message, eventContent = null) {
|
||||
if (errorLogCount < MAX_ERROR_LOGS) {
|
||||
console.error(message);
|
||||
@@ -31,8 +30,8 @@ function logErrorOnce(message, eventContent = null) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A very naive "encryption" function that just reverses the string.
|
||||
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
|
||||
* Example "encryption" that just reverses strings.
|
||||
* In real usage, swap with actual crypto.
|
||||
*/
|
||||
function fakeEncrypt(magnet) {
|
||||
return magnet.split("").reverse().join("");
|
||||
@@ -41,18 +40,63 @@ function fakeDecrypt(encrypted) {
|
||||
return encrypted.split("").reverse().join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a raw Nostr event => your "video" object.
|
||||
*/
|
||||
function convertEventToVideo(event) {
|
||||
const content = JSON.parse(event.content || "{}");
|
||||
return {
|
||||
id: event.id,
|
||||
// We store a 'videoRootId' in content so we can group multiple edits
|
||||
videoRootId: content.videoRootId || null,
|
||||
version: content.version ?? 1,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
deleted: content.deleted === true,
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Key each "active" video by its root ID => so you only store
|
||||
* the newest version for each root. But for older events w/o videoRootId,
|
||||
* or w/o 'd' tag, we handle fallback logic below.
|
||||
*/
|
||||
function getActiveKey(video) {
|
||||
// If it has a videoRootId, we use that
|
||||
if (video.videoRootId) {
|
||||
return `ROOT:${video.videoRootId}`;
|
||||
}
|
||||
// Otherwise fallback to (pubkey + dTag) or if no dTag, fallback to event.id
|
||||
// This is a fallback approach so older events appear in the "active map".
|
||||
const dTag = video.tags?.find((t) => t[0] === "d");
|
||||
if (dTag) {
|
||||
return `${video.pubkey}:${dTag[1]}`;
|
||||
}
|
||||
return `LEGACY:${video.id}`;
|
||||
}
|
||||
|
||||
class NostrClient {
|
||||
constructor() {
|
||||
this.pool = null;
|
||||
this.pubkey = null;
|
||||
this.relays = RELAY_URLS;
|
||||
|
||||
// We keep a Map of subscribed videos for quick lookups by event.id
|
||||
this.subscribedVideos = new Map();
|
||||
// All events—old or new—so older share links still work
|
||||
this.allEvents = new Map();
|
||||
|
||||
// "activeMap" holds only the newest version for each root ID (or fallback).
|
||||
this.activeMap = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the Nostr client by connecting to relays.
|
||||
* Connect to all configured relays
|
||||
*/
|
||||
async init() {
|
||||
if (isDevMode) console.log("Connecting to relays...");
|
||||
@@ -63,18 +107,16 @@ class NostrClient {
|
||||
const successfulRelays = results
|
||||
.filter((r) => r.success)
|
||||
.map((r) => r.url);
|
||||
|
||||
if (successfulRelays.length === 0) throw new Error("No relays connected");
|
||||
|
||||
if (isDevMode)
|
||||
if (isDevMode) {
|
||||
console.log(`Connected to ${successfulRelays.length} relay(s)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Nostr init failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to handle relay connections
|
||||
async connectToRelays() {
|
||||
return Promise.all(
|
||||
this.relays.map(
|
||||
@@ -100,49 +142,40 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in the user using a Nostr extension or by entering an NSEC key.
|
||||
* Attempt Nostr extension login or abort
|
||||
*/
|
||||
async login() {
|
||||
try {
|
||||
if (!window.nostr) {
|
||||
console.log("No Nostr extension found");
|
||||
throw new Error(
|
||||
"Please install a Nostr extension (like Alby or nos2x)."
|
||||
"Please install a Nostr extension (Alby, nos2x, etc.)."
|
||||
);
|
||||
}
|
||||
|
||||
const pubkey = await window.nostr.getPublicKey();
|
||||
const npub = window.NostrTools.nip19.npubEncode(pubkey);
|
||||
|
||||
// Debug logs
|
||||
if (isDevMode) {
|
||||
console.log("Got pubkey:", pubkey);
|
||||
console.log("Converted to npub:", npub);
|
||||
console.log("Whitelist:", accessControl.getWhitelist());
|
||||
console.log("Blacklist:", accessControl.getBlacklist());
|
||||
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
|
||||
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
|
||||
}
|
||||
|
||||
// Check access control
|
||||
// Access control check
|
||||
if (!accessControl.canAccess(npub)) {
|
||||
if (accessControl.isBlacklisted(npub)) {
|
||||
throw new Error(
|
||||
"Your account has been blocked from accessing this platform."
|
||||
);
|
||||
throw new Error("Your account has been blocked on this platform.");
|
||||
} else {
|
||||
throw new Error(
|
||||
"Access is currently restricted to whitelisted users only."
|
||||
);
|
||||
throw new Error("Access restricted to whitelisted users only.");
|
||||
}
|
||||
}
|
||||
|
||||
this.pubkey = pubkey;
|
||||
if (isDevMode)
|
||||
console.log(
|
||||
"Successfully logged in with extension. Public key:",
|
||||
this.pubkey
|
||||
);
|
||||
if (isDevMode) {
|
||||
console.log("Logged in with extension. Pubkey:", this.pubkey);
|
||||
}
|
||||
return this.pubkey;
|
||||
} catch (e) {
|
||||
console.error("Login error:", e);
|
||||
@@ -150,17 +183,11 @@ class NostrClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs out the user.
|
||||
*/
|
||||
logout() {
|
||||
this.pubkey = null;
|
||||
if (isDevMode) console.log("User logged out.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes an NSEC key.
|
||||
*/
|
||||
decodeNsec(nsec) {
|
||||
try {
|
||||
const { data } = window.NostrTools.nip19.decode(nsec);
|
||||
@@ -171,271 +198,253 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes a new video event to all relays (creates a brand-new note).
|
||||
* Publish a *new* video with a brand-new d tag & brand-new videoRootId
|
||||
*/
|
||||
async publishVideo(videoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (!pubkey) throw new Error("Not logged in to publish video.");
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Publishing video with data:", videoData);
|
||||
console.log("Publishing new video with data:", videoData);
|
||||
}
|
||||
|
||||
// If user sets "isPrivate = true", encrypt the magnet
|
||||
let finalMagnet = videoData.magnet;
|
||||
if (videoData.isPrivate === true) {
|
||||
if (videoData.isPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalMagnet);
|
||||
}
|
||||
|
||||
// Default version is 1 if not specified
|
||||
const version = videoData.version ?? 1;
|
||||
// new "videoRootId" ensures all future edits know they're from the same root
|
||||
const videoRootId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const dTagValue = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
const uniqueD = `${Date.now()}-${Math.random()
|
||||
.toString(36)
|
||||
.substring(2, 10)}`;
|
||||
|
||||
// Always mark "deleted" false for new posts
|
||||
const contentObject = {
|
||||
version,
|
||||
videoRootId,
|
||||
version: videoData.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: videoData.isPrivate || false,
|
||||
title: videoData.title,
|
||||
isPrivate: videoData.isPrivate ?? false,
|
||||
title: videoData.title || "",
|
||||
magnet: finalMagnet,
|
||||
thumbnail: videoData.thumbnail,
|
||||
description: videoData.description,
|
||||
mode: videoData.mode,
|
||||
thumbnail: videoData.thumbnail || "",
|
||||
description: videoData.description || "",
|
||||
mode: videoData.mode || "live",
|
||||
};
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 100),
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", uniqueD],
|
||||
["d", dTagValue],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Event content after stringify:", event.content);
|
||||
console.log("Using d tag:", uniqueD);
|
||||
console.log("Publish event with brand-new root:", videoRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed event:", signedEvent);
|
||||
}
|
||||
if (isDevMode) console.log("Signed event:", signedEvent);
|
||||
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(`Event published to ${url}`);
|
||||
}
|
||||
if (isDevMode) console.log(`Video published to ${url}`);
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(`Failed to publish to ${url}:`, err.message);
|
||||
}
|
||||
if (isDevMode) console.error(`Failed to publish: ${url}`, err);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign event.");
|
||||
} catch (err) {
|
||||
if (isDevMode) console.error("Failed to sign/publish:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits an existing video event by reusing the same "d" tag.
|
||||
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
|
||||
* Edits a video by creating a *new event* with a brand-new d tag,
|
||||
* but reuses the same videoRootId as the original.
|
||||
* => old link remains pinned to the old event, new link is a fresh ID.
|
||||
*/
|
||||
async editVideo(originalEvent, updatedVideoData, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
async editVideo(originalVideo, updatedData, pubkey) {
|
||||
if (!pubkey) throw new Error("Not logged in to edit.");
|
||||
if (originalVideo.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this video (different pubkey).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Editing video event:", originalEvent);
|
||||
console.log("New video data:", updatedVideoData);
|
||||
}
|
||||
// Use the videoRootId directly from the converted video
|
||||
const rootId = originalVideo.videoRootId || null;
|
||||
|
||||
// Grab the d tag from the original event
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error(
|
||||
'This event has no "d" tag, cannot edit as addressable kind=30078.'
|
||||
);
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
// Parse old content
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
if (isDevMode) {
|
||||
console.log("Old content:", oldContent);
|
||||
}
|
||||
|
||||
// Keep old version & deleted status
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
const oldDeleted = oldContent.deleted === true;
|
||||
const newVersion = updatedVideoData.version ?? oldVersion;
|
||||
|
||||
const oldWasPrivate = oldContent.isPrivate === true;
|
||||
|
||||
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
|
||||
let oldPlainMagnet = oldContent.magnet || "";
|
||||
if (oldWasPrivate && oldPlainMagnet) {
|
||||
// Decrypt the old magnet if it was private
|
||||
let oldPlainMagnet = originalVideo.magnet || "";
|
||||
if (originalVideo.isPrivate && oldPlainMagnet) {
|
||||
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
|
||||
}
|
||||
|
||||
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
|
||||
const newIsPrivate =
|
||||
typeof updatedVideoData.isPrivate === "boolean"
|
||||
? updatedVideoData.isPrivate
|
||||
: oldContent.isPrivate ?? false;
|
||||
// Determine new privacy setting
|
||||
const wantPrivate =
|
||||
updatedData.isPrivate ?? originalVideo.isPrivate ?? false;
|
||||
|
||||
// 3) The user might type a new magnet or keep oldPlainMagnet
|
||||
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
|
||||
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
|
||||
// Fallback to old magnet if none provided
|
||||
let finalPlainMagnet = (updatedData.magnet || "").trim();
|
||||
if (!finalPlainMagnet) {
|
||||
finalPlainMagnet = oldPlainMagnet;
|
||||
}
|
||||
|
||||
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
|
||||
// Re-encrypt if user wants private
|
||||
let finalMagnet = finalPlainMagnet;
|
||||
if (newIsPrivate) {
|
||||
if (wantPrivate) {
|
||||
finalMagnet = fakeEncrypt(finalPlainMagnet);
|
||||
}
|
||||
|
||||
// If there's no root yet (legacy), generate it
|
||||
const newRootId =
|
||||
rootId || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
const newD = `${Date.now()}-edit-${Math.random().toString(36).slice(2)}`;
|
||||
|
||||
// Build updated content
|
||||
const contentObject = {
|
||||
version: newVersion,
|
||||
deleted: oldDeleted,
|
||||
isPrivate: newIsPrivate,
|
||||
title: updatedVideoData.title,
|
||||
videoRootId: newRootId,
|
||||
version: updatedData.version ?? originalVideo.version ?? 1,
|
||||
deleted: false,
|
||||
isPrivate: wantPrivate,
|
||||
title: updatedData.title ?? originalVideo.title,
|
||||
magnet: finalMagnet,
|
||||
thumbnail: updatedVideoData.thumbnail,
|
||||
description: updatedVideoData.description,
|
||||
mode: updatedVideoData.mode,
|
||||
thumbnail: updatedData.thumbnail ?? originalVideo.thumbnail,
|
||||
description: updatedData.description ?? originalVideo.description,
|
||||
mode: updatedData.mode ?? originalVideo.mode ?? "live",
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Building updated content object:", contentObject);
|
||||
}
|
||||
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
["d", existingD],
|
||||
["d", newD], // new share link
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag:", existingD);
|
||||
console.log("Updated event content:", event.content);
|
||||
console.log("Creating edited event with root ID:", newRootId);
|
||||
console.log("Event content:", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
const signedEvent = await window.nostr.signEvent(event);
|
||||
if (isDevMode) {
|
||||
console.log("Signed edited event:", signedEvent);
|
||||
}
|
||||
|
||||
// Publish to all relays
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Edited event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error(
|
||||
`Failed to publish edited event to ${url}:`,
|
||||
err.message
|
||||
);
|
||||
console.error(`Publish failed to ${url}`, err);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign edited event:", error.message);
|
||||
}
|
||||
throw new Error("Failed to sign edited event.");
|
||||
} catch (err) {
|
||||
console.error("Edit failed:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft-delete or hide an existing video by marking content as "deleted: true"
|
||||
* and republishing with the same (kind=30078, pubkey, d) address.
|
||||
* "Deleting" => we just mark content as {deleted:true} and blank out magnet/desc
|
||||
*/
|
||||
async deleteVideo(originalEvent, pubkey) {
|
||||
if (!pubkey) {
|
||||
throw new Error("User is not logged in.");
|
||||
throw new Error("Not logged in to delete.");
|
||||
}
|
||||
if (originalEvent.pubkey !== pubkey) {
|
||||
throw new Error("You do not own this event (different pubkey).");
|
||||
throw new Error("Not your event (pubkey mismatch).");
|
||||
}
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Deleting video event:", originalEvent);
|
||||
// If front-end didn't pass the tags array, load the full event from local or from the relay:
|
||||
let baseEvent = originalEvent;
|
||||
if (!baseEvent.tags || !Array.isArray(baseEvent.tags)) {
|
||||
const fetched = await this.getEventById(originalEvent.id);
|
||||
if (!fetched) {
|
||||
throw new Error("Could not fetch the original event for deletion.");
|
||||
}
|
||||
// Rebuild baseEvent as a raw Nostr event that includes .tags and .content
|
||||
baseEvent = {
|
||||
id: fetched.id,
|
||||
pubkey: fetched.pubkey,
|
||||
// put the raw JSON content back into string form:
|
||||
content: JSON.stringify({
|
||||
version: fetched.version,
|
||||
deleted: fetched.deleted,
|
||||
isPrivate: fetched.isPrivate,
|
||||
title: fetched.title,
|
||||
magnet: fetched.magnet,
|
||||
thumbnail: fetched.thumbnail,
|
||||
description: fetched.description,
|
||||
mode: fetched.mode,
|
||||
}),
|
||||
tags: fetched.tags,
|
||||
};
|
||||
}
|
||||
|
||||
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
|
||||
// Now try to get the old d-tag
|
||||
const dTag = baseEvent.tags.find((t) => t[0] === "d");
|
||||
if (!dTag) {
|
||||
throw new Error(
|
||||
'This event has no "d" tag, cannot delete as addressable kind=30078.'
|
||||
);
|
||||
throw new Error('No "d" tag => cannot delete addressable kind=30078.');
|
||||
}
|
||||
const existingD = dTag[1];
|
||||
|
||||
const oldContent = JSON.parse(originalEvent.content || "{}");
|
||||
// After you've parsed oldContent:
|
||||
const oldContent = JSON.parse(baseEvent.content || "{}");
|
||||
const oldVersion = oldContent.version ?? 1;
|
||||
|
||||
// Mark it "deleted" and clear out magnet, thumbnail, etc.
|
||||
// ADD this block to handle the old root or fallback:
|
||||
let finalRootId = oldContent.videoRootId || null;
|
||||
if (!finalRootId) {
|
||||
// If it’s a legacy video (no root), we can fallback to your
|
||||
// existing logic used by getActiveKey. For instance, if it had a 'd' tag:
|
||||
if (dTag) {
|
||||
// Some devs store it as 'LEGACY:pubkey:dTagValue'
|
||||
// or you could just store the same as the old approach:
|
||||
finalRootId = `LEGACY:${baseEvent.pubkey}:${dTag[1]}`;
|
||||
} else {
|
||||
finalRootId = `LEGACY:${baseEvent.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Now build the content object, including videoRootId:
|
||||
const contentObject = {
|
||||
videoRootId: finalRootId, // <-- CRUCIAL so the delete event shares the same root key
|
||||
version: oldVersion,
|
||||
deleted: true,
|
||||
isPrivate: oldContent.isPrivate ?? false,
|
||||
title: oldContent.title || "",
|
||||
magnet: "",
|
||||
thumbnail: "",
|
||||
description: "This video has been deleted.",
|
||||
description: "Video was deleted by creator.",
|
||||
mode: oldContent.mode || "live",
|
||||
isPrivate: oldContent.isPrivate || false,
|
||||
};
|
||||
|
||||
// Reuse the same d-tag for an addressable edit
|
||||
const event = {
|
||||
kind: 30078,
|
||||
pubkey,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
["t", "video"],
|
||||
// We reuse the same d => overshadow the original event
|
||||
["d", existingD],
|
||||
],
|
||||
content: JSON.stringify(contentObject),
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("Reusing d tag for delete:", existingD);
|
||||
console.log("Deleted event content:", event.content);
|
||||
console.log("Deleting video => mark 'deleted:true'.", event.content);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -444,14 +453,13 @@ class NostrClient {
|
||||
console.log("Signed deleted event:", signedEvent);
|
||||
}
|
||||
|
||||
// Publish everywhere
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
try {
|
||||
await this.pool.publish([url], signedEvent);
|
||||
if (isDevMode) {
|
||||
console.log(
|
||||
`Deleted event published to ${url} (d="${existingD}")`
|
||||
);
|
||||
console.log(`Delete event published to ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
@@ -460,80 +468,67 @@ class NostrClient {
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
return signedEvent;
|
||||
} catch (error) {
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("Failed to sign deleted event:", error);
|
||||
console.error("Failed to sign deleted event:", err);
|
||||
}
|
||||
throw new Error("Failed to sign deleted event.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to video events from all configured relays, storing them in a Map.
|
||||
*
|
||||
* @param {Function} onVideo - Callback fired for each new/updated video
|
||||
* Subscribes to *all* video events. We store them in this.allEvents so older
|
||||
* notes remain accessible by ID, plus we maintain this.activeMap for the newest
|
||||
* version of each root (or fallback).
|
||||
*/
|
||||
subscribeVideos(onVideo) {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 500, // Adjust as needed
|
||||
limit: 500,
|
||||
since: 0,
|
||||
};
|
||||
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Subscribing with filter:", filter);
|
||||
}
|
||||
|
||||
// Create subscription across all relays
|
||||
const sub = this.pool.sub(this.relays, [filter]);
|
||||
|
||||
sub.on("event", (event) => {
|
||||
try {
|
||||
const content = JSON.parse(event.content);
|
||||
const video = convertEventToVideo(event);
|
||||
this.allEvents.set(event.id, video);
|
||||
|
||||
// If it’s marked deleted, remove from active map if it’s the active version
|
||||
// NEW CODE
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
// Don't compare IDs—just remove that key from the active map
|
||||
this.activeMap.delete(activeKey);
|
||||
|
||||
// (Optional) If you want a debug log:
|
||||
// console.log(`[DELETE] Removed activeKey=${activeKey}`);
|
||||
|
||||
// If marked deleted
|
||||
if (content.deleted === true) {
|
||||
// Remove it from our Map if we had it
|
||||
if (this.subscribedVideos.has(event.id)) {
|
||||
this.subscribedVideos.delete(event.id);
|
||||
// Optionally notify the callback so UI can remove it
|
||||
// onVideo(null, { deletedId: event.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Construct a video object
|
||||
const video = {
|
||||
id: event.id,
|
||||
version: content.version ?? 1,
|
||||
isPrivate: content.isPrivate ?? false,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
pubkey: event.pubkey,
|
||||
created_at: event.created_at,
|
||||
tags: event.tags,
|
||||
};
|
||||
|
||||
// Check if we already have it in our Map
|
||||
if (!this.subscribedVideos.has(event.id)) {
|
||||
// It's new, so store it
|
||||
this.subscribedVideos.set(event.id, video);
|
||||
// Then notify the callback that a new video arrived
|
||||
// Not deleted => see if it’s the newest
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
if (!prevActive) {
|
||||
// brand new => set it
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
} else {
|
||||
// Optional: if you want to detect edits, compare the new vs. old and update
|
||||
// this.subscribedVideos.set(event.id, video);
|
||||
// onVideo(video) to re-render, etc.
|
||||
// compare timestamps
|
||||
if (video.created_at > prevActive.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error parsing event:", err);
|
||||
console.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -542,61 +537,60 @@ class NostrClient {
|
||||
if (isDevMode) {
|
||||
console.log("[subscribeVideos] Reached EOSE for all relays");
|
||||
}
|
||||
// Optionally: onVideo(null, { eose: true }) to signal initial load done
|
||||
});
|
||||
|
||||
return sub; // so you can unsub later if needed
|
||||
return sub;
|
||||
}
|
||||
|
||||
/**
|
||||
* A one-time, bulk fetch of videos from all configured relays.
|
||||
* (Limit has been reduced to 300 for better performance.)
|
||||
* Bulk fetch from all relays, store in allEvents, rebuild activeMap
|
||||
*/
|
||||
async fetchVideos() {
|
||||
const filter = {
|
||||
kinds: [30078],
|
||||
"#t": ["video"],
|
||||
limit: 300, // Reduced from 1000 for quicker fetches
|
||||
limit: 300,
|
||||
since: 0,
|
||||
};
|
||||
const videoEvents = new Map();
|
||||
|
||||
const localAll = new Map();
|
||||
try {
|
||||
// Query each relay in parallel
|
||||
// 1) Fetch all events from each relay
|
||||
await Promise.all(
|
||||
this.relays.map(async (url) => {
|
||||
const events = await this.pool.list([url], [filter]);
|
||||
for (const evt of events) {
|
||||
try {
|
||||
const content = JSON.parse(evt.content);
|
||||
if (content.deleted) {
|
||||
videoEvents.delete(evt.id);
|
||||
} else {
|
||||
videoEvents.set(evt.id, {
|
||||
id: evt.id,
|
||||
pubkey: evt.pubkey,
|
||||
created_at: evt.created_at,
|
||||
title: content.title || "",
|
||||
magnet: content.magnet || "",
|
||||
thumbnail: content.thumbnail || "",
|
||||
description: content.description || "",
|
||||
mode: content.mode || "live",
|
||||
isPrivate: content.isPrivate || false,
|
||||
tags: evt.tags,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error parsing event content:", e);
|
||||
}
|
||||
const vid = convertEventToVideo(evt);
|
||||
localAll.set(evt.id, vid);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Turn the Map into a sorted array
|
||||
const allVideos = Array.from(videoEvents.values()).sort(
|
||||
// 2) Merge into this.allEvents
|
||||
for (const [id, vid] of localAll.entries()) {
|
||||
this.allEvents.set(id, vid);
|
||||
}
|
||||
|
||||
// 3) Rebuild activeMap
|
||||
this.activeMap.clear();
|
||||
for (const [id, video] of this.allEvents.entries()) {
|
||||
// Skip if the video is marked deleted
|
||||
if (video.deleted) continue;
|
||||
|
||||
const activeKey = getActiveKey(video);
|
||||
const existing = this.activeMap.get(activeKey);
|
||||
|
||||
// If there's no existing entry or this is newer, set/replace
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
}
|
||||
}
|
||||
|
||||
// 4) Return newest version for each root in descending order
|
||||
const activeVideos = Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
return allVideos;
|
||||
return activeVideos;
|
||||
} catch (err) {
|
||||
console.error("fetchVideos error:", err);
|
||||
return [];
|
||||
@@ -604,46 +598,38 @@ class NostrClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates video content structure.
|
||||
* Attempt to fetch an event by ID from local cache, then from the relays
|
||||
*/
|
||||
isValidVideo(content) {
|
||||
try {
|
||||
const isValid =
|
||||
content &&
|
||||
typeof content === "object" &&
|
||||
typeof content.title === "string" &&
|
||||
content.title.length > 0 &&
|
||||
typeof content.magnet === "string" &&
|
||||
content.magnet.length > 0 &&
|
||||
typeof content.mode === "string" &&
|
||||
["dev", "live"].includes(content.mode) &&
|
||||
(typeof content.thumbnail === "string" ||
|
||||
typeof content.thumbnail === "undefined") &&
|
||||
(typeof content.description === "string" ||
|
||||
typeof content.description === "undefined");
|
||||
|
||||
if (isDevMode && !isValid) {
|
||||
console.log("Invalid video content:", content);
|
||||
console.log("Validation details:", {
|
||||
hasTitle: typeof content.title === "string",
|
||||
hasMagnet: typeof content.magnet === "string",
|
||||
hasMode: typeof content.mode === "string",
|
||||
validThumbnail:
|
||||
typeof content.thumbnail === "string" ||
|
||||
typeof content.thumbnail === "undefined",
|
||||
validDescription:
|
||||
typeof content.description === "string" ||
|
||||
typeof content.description === "undefined",
|
||||
});
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.error("Error validating video:", error);
|
||||
}
|
||||
return false;
|
||||
async getEventById(eventId) {
|
||||
const local = this.allEvents.get(eventId);
|
||||
if (local) {
|
||||
return local;
|
||||
}
|
||||
// direct fetch if missing
|
||||
try {
|
||||
for (const url of this.relays) {
|
||||
const maybeEvt = await this.pool.get([url], { ids: [eventId] });
|
||||
if (maybeEvt && maybeEvt.id === eventId) {
|
||||
const video = convertEventToVideo(maybeEvt);
|
||||
this.allEvents.set(eventId, video);
|
||||
return video;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("getEventById direct fetch error:", err);
|
||||
}
|
||||
}
|
||||
return null; // not found
|
||||
}
|
||||
|
||||
/**
|
||||
* Return newest versions from activeMap if you want to skip older events
|
||||
*/
|
||||
getActiveVideos() {
|
||||
return Array.from(this.activeMap.values()).sort(
|
||||
(a, b) => b.created_at - a.created_at
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
17
src/js/viewManager.js
Normal file
17
src/js/viewManager.js
Normal file
@@ -0,0 +1,17 @@
|
||||
// js/viewManager.js
|
||||
|
||||
// Load a partial view by URL into the #viewContainer
|
||||
export async function loadView(viewUrl) {
|
||||
try {
|
||||
const res = await fetch(viewUrl);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load view: ${res.status}`);
|
||||
}
|
||||
const html = await res.text();
|
||||
document.getElementById("viewContainer").innerHTML = html;
|
||||
} catch (err) {
|
||||
console.error("View loading error:", err);
|
||||
document.getElementById("viewContainer").innerHTML =
|
||||
"<p class='text-center text-red-500'>Failed to load content.</p>";
|
||||
}
|
||||
}
|
@@ -1,16 +1,14 @@
|
||||
// <!-- keep this <ai_context> section if it already exists at the top of your file -->
|
||||
|
||||
// js/webtorrent.js
|
||||
|
||||
import WebTorrent from "./webtorrent.min.js";
|
||||
|
||||
export class TorrentClient {
|
||||
constructor() {
|
||||
// Create WebTorrent client
|
||||
this.client = new WebTorrent();
|
||||
this.currentTorrent = null;
|
||||
this.TIMEOUT_DURATION = 60000; // 60 seconds
|
||||
this.statsInterval = null;
|
||||
// We remove the “statsInterval” since we’re not using it here anymore
|
||||
// this.statsInterval = null;
|
||||
}
|
||||
|
||||
log(msg) {
|
||||
@@ -62,9 +60,6 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the service worker, waiting until it's fully active before proceeding.
|
||||
*/
|
||||
async setupServiceWorker() {
|
||||
try {
|
||||
const isBraveBrowser = await this.isBrave();
|
||||
@@ -72,21 +67,18 @@ export class TorrentClient {
|
||||
if (!window.isSecureContext) {
|
||||
throw new Error("HTTPS or localhost required");
|
||||
}
|
||||
|
||||
if (!("serviceWorker" in navigator) || !navigator.serviceWorker) {
|
||||
throw new Error("Service Worker not supported or disabled");
|
||||
}
|
||||
|
||||
// If Brave, we optionally clear all service workers so we can re-register cleanly
|
||||
// Optional Brave config
|
||||
if (isBraveBrowser) {
|
||||
this.log("Checking Brave configuration...");
|
||||
|
||||
if (!navigator.serviceWorker) {
|
||||
throw new Error(
|
||||
"Please enable Service Workers in Brave Shield settings"
|
||||
);
|
||||
}
|
||||
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error("Please enable WebRTC in Brave Shield settings");
|
||||
}
|
||||
@@ -134,11 +126,9 @@ export class TorrentClient {
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for service worker to become active
|
||||
await this.waitForServiceWorkerActivation(registration);
|
||||
this.log("Service worker activated");
|
||||
|
||||
// Make sure it’s truly active
|
||||
const readyRegistration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((_, reject) =>
|
||||
@@ -170,7 +160,8 @@ export class TorrentClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the given magnet URI to the specified <video> element.
|
||||
* Streams the magnet to the <video> element.
|
||||
* No stats intervals here—just returns the torrent object.
|
||||
*/
|
||||
async streamVideo(magnetURI, videoElement) {
|
||||
try {
|
||||
@@ -180,314 +171,127 @@ export class TorrentClient {
|
||||
throw new Error("Service worker setup failed");
|
||||
}
|
||||
|
||||
// 2) Create WebTorrent server AFTER service worker is ready
|
||||
// 2) Create WebTorrent server
|
||||
this.client.createServer({ controller: registration });
|
||||
this.log("WebTorrent server created");
|
||||
|
||||
const isFirefoxBrowser = this.isFirefox();
|
||||
|
||||
if (isFirefoxBrowser) {
|
||||
// ----------------------
|
||||
// FIREFOX CODE PATH
|
||||
// (sequential, concurrency limit, smaller chunk)
|
||||
// ----------------------
|
||||
return new Promise((resolve, reject) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (isFirefoxBrowser) {
|
||||
this.log("Starting torrent download (Firefox path)");
|
||||
this.client.add(
|
||||
magnetURI,
|
||||
{
|
||||
strategy: "sequential",
|
||||
maxWebConns: 4, // reduce concurrency
|
||||
},
|
||||
{ strategy: "sequential", maxWebConns: 4 },
|
||||
(torrent) => {
|
||||
this.log("Torrent added (Firefox path):", torrent.name);
|
||||
this.handleFirefoxTorrent(torrent, videoElement, resolve, reject);
|
||||
}
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// ----------------------
|
||||
// CHROME / OTHER BROWSERS CODE PATH
|
||||
// (your original "faster" approach)
|
||||
// ----------------------
|
||||
return new Promise((resolve, reject) => {
|
||||
} else {
|
||||
this.log("Starting torrent download (Chrome path)");
|
||||
this.client.add(magnetURI, (torrent) => {
|
||||
this.log("Torrent added (Chrome path):", torrent.name);
|
||||
this.handleChromeTorrent(torrent, videoElement, resolve, reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.log("Failed to setup video streaming:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The "faster" original approach for Chrome/other browsers.
|
||||
*/
|
||||
// Minimal handleChromeTorrent — no internal setInterval
|
||||
handleChromeTorrent(torrent, videoElement, resolve, reject) {
|
||||
this.log("Torrent added (Chrome path): " + torrent.name);
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const progress = document.getElementById("progress");
|
||||
const peers = document.getElementById("peers");
|
||||
const speed = document.getElementById("speed");
|
||||
const downloaded = document.getElementById("downloaded");
|
||||
|
||||
if (status) {
|
||||
status.textContent = `Loading ${torrent.name}...`;
|
||||
}
|
||||
|
||||
// Find playable file (same as old code)
|
||||
const file = torrent.files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".mp4") ||
|
||||
f.name.endsWith(".webm") ||
|
||||
f.name.endsWith(".mkv")
|
||||
const file = torrent.files.find((f) =>
|
||||
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||
);
|
||||
if (!file) {
|
||||
const error = new Error("No compatible video file found in torrent");
|
||||
this.log(error.message);
|
||||
if (status) status.textContent = "Error: No video file found";
|
||||
return reject(error);
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
// Mute for autoplay
|
||||
// Mute & crossOrigin
|
||||
videoElement.muted = true;
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
// Error handling same as old code
|
||||
// Catch video errors
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
const errObj = e.target.error;
|
||||
this.log("Video error:", errObj);
|
||||
if (errObj) {
|
||||
this.log("Error code:", errObj.code);
|
||||
this.log("Error message:", errObj.message);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent =
|
||||
"Error playing video. Try refreshing the page.";
|
||||
}
|
||||
this.log("Video error:", e.target.error);
|
||||
});
|
||||
|
||||
// Attempt autoplay
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
const playPromise = videoElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => this.log("Autoplay started (Chrome path)"))
|
||||
.catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
if (status) status.textContent = "Click to play video";
|
||||
videoElement.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
videoElement
|
||||
.play()
|
||||
.catch((err2) => this.log("Play failed:", err2));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", () => {
|
||||
this.log("Video metadata loaded (Chrome path)");
|
||||
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
|
||||
this.log("Invalid duration, attempting to fix...");
|
||||
videoElement.currentTime = 1e101;
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Now stream to the video element
|
||||
// Actually stream
|
||||
try {
|
||||
file.streamTo(videoElement); // no chunk constraints
|
||||
this.log("Streaming started (Chrome path)");
|
||||
|
||||
// Update stats
|
||||
this.statsInterval = setInterval(() => {
|
||||
if (!document.body.contains(videoElement)) {
|
||||
clearInterval(this.statsInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const percentage = torrent.progress * 100;
|
||||
if (progress) progress.style.width = `${percentage}%`;
|
||||
if (peers) peers.textContent = `Peers: ${torrent.numPeers}`;
|
||||
if (speed) {
|
||||
speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s`;
|
||||
}
|
||||
if (downloaded) {
|
||||
downloaded.textContent = `${this.formatBytes(
|
||||
torrent.downloaded
|
||||
)} / ${this.formatBytes(torrent.length)}`;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent =
|
||||
torrent.progress === 1
|
||||
? `${torrent.name}`
|
||||
: `Loading ${torrent.name}...`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
file.streamTo(videoElement);
|
||||
this.currentTorrent = torrent;
|
||||
resolve();
|
||||
resolve(torrent);
|
||||
} catch (err) {
|
||||
this.log("Streaming error (Chrome path):", err);
|
||||
if (status) status.textContent = "Error starting video stream";
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// Torrent error event
|
||||
// Also handle torrent error events
|
||||
torrent.on("error", (err) => {
|
||||
this.log("Torrent error (Chrome path):", err);
|
||||
if (status) status.textContent = "Error loading video";
|
||||
clearInterval(this.statsInterval);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The new approach for Firefox: sequential, concurrency limit, smaller chunk size.
|
||||
*/
|
||||
// Minimal handleFirefoxTorrent
|
||||
handleFirefoxTorrent(torrent, videoElement, resolve, reject) {
|
||||
this.log("Torrent added (Firefox path): " + torrent.name);
|
||||
|
||||
const status = document.getElementById("status");
|
||||
const progress = document.getElementById("progress");
|
||||
const peers = document.getElementById("peers");
|
||||
const speed = document.getElementById("speed");
|
||||
const downloaded = document.getElementById("downloaded");
|
||||
|
||||
if (status) {
|
||||
status.textContent = `Loading ${torrent.name}...`;
|
||||
}
|
||||
|
||||
// Find playable file
|
||||
const file = torrent.files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".mp4") ||
|
||||
f.name.endsWith(".webm") ||
|
||||
f.name.endsWith(".mkv")
|
||||
const file = torrent.files.find((f) =>
|
||||
/\.(mp4|webm|mkv)$/.test(f.name.toLowerCase())
|
||||
);
|
||||
if (!file) {
|
||||
const error = new Error("No compatible video file found in torrent");
|
||||
this.log(error.message);
|
||||
if (status) status.textContent = "Error: No video file found";
|
||||
return reject(error);
|
||||
return reject(new Error("No compatible video file found in torrent"));
|
||||
}
|
||||
|
||||
videoElement.muted = true;
|
||||
videoElement.crossOrigin = "anonymous";
|
||||
|
||||
videoElement.addEventListener("error", (e) => {
|
||||
const errObj = e.target.error;
|
||||
this.log("Video error (Firefox path):", errObj);
|
||||
if (errObj) {
|
||||
this.log("Error code:", errObj.code);
|
||||
this.log("Error message:", errObj.message);
|
||||
}
|
||||
if (status) {
|
||||
status.textContent =
|
||||
"Error playing video. Try refreshing the page.";
|
||||
}
|
||||
this.log("Video error (Firefox path):", e.target.error);
|
||||
});
|
||||
|
||||
videoElement.addEventListener("canplay", () => {
|
||||
const playPromise = videoElement.play();
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => this.log("Autoplay started (Firefox path)"))
|
||||
.catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
if (status) status.textContent = "Click to play video";
|
||||
videoElement.addEventListener(
|
||||
"click",
|
||||
() => {
|
||||
videoElement
|
||||
.play()
|
||||
.catch((err2) => this.log("Play failed:", err2));
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
});
|
||||
}
|
||||
videoElement.play().catch((err) => {
|
||||
this.log("Autoplay failed:", err);
|
||||
});
|
||||
});
|
||||
|
||||
videoElement.addEventListener("loadedmetadata", () => {
|
||||
this.log("Video metadata loaded (Firefox path)");
|
||||
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
|
||||
this.log("Invalid duration, attempting to fix...");
|
||||
videoElement.currentTime = 1e101;
|
||||
videoElement.currentTime = 0;
|
||||
}
|
||||
});
|
||||
|
||||
// We set a smaller chunk size for Firefox
|
||||
try {
|
||||
file.streamTo(videoElement, { highWaterMark: 32 * 1024 }); // 32 KB chunk
|
||||
this.log("Streaming started (Firefox path)");
|
||||
|
||||
this.statsInterval = setInterval(() => {
|
||||
if (!document.body.contains(videoElement)) {
|
||||
clearInterval(this.statsInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const percentage = torrent.progress * 100;
|
||||
if (progress) progress.style.width = `${percentage}%`;
|
||||
if (peers) peers.textContent = `Peers: ${torrent.numPeers}`;
|
||||
if (speed) {
|
||||
speed.textContent = `${this.formatBytes(torrent.downloadSpeed)}/s`;
|
||||
}
|
||||
if (downloaded) {
|
||||
downloaded.textContent = `${this.formatBytes(
|
||||
torrent.downloaded
|
||||
)} / ${this.formatBytes(torrent.length)}`;
|
||||
}
|
||||
|
||||
if (status) {
|
||||
status.textContent =
|
||||
torrent.progress === 1
|
||||
? `${torrent.name}`
|
||||
: `Loading ${torrent.name}...`;
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
file.streamTo(videoElement, { highWaterMark: 32 * 1024 });
|
||||
this.currentTorrent = torrent;
|
||||
resolve();
|
||||
resolve(torrent);
|
||||
} catch (err) {
|
||||
this.log("Streaming error (Firefox path):", err);
|
||||
if (status) status.textContent = "Error starting video stream";
|
||||
reject(err);
|
||||
}
|
||||
|
||||
// Listen for torrent errors
|
||||
torrent.on("error", (err) => {
|
||||
this.log("Torrent error (Firefox path):", err);
|
||||
if (status) status.textContent = "Error loading video";
|
||||
clearInterval(this.statsInterval);
|
||||
reject(err);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up after playback or page unload.
|
||||
* Clean up
|
||||
*/
|
||||
async cleanup() {
|
||||
try {
|
||||
if (this.statsInterval) {
|
||||
clearInterval(this.statsInterval);
|
||||
}
|
||||
// No local interval to clear here
|
||||
if (this.currentTorrent) {
|
||||
this.currentTorrent.destroy();
|
||||
}
|
||||
if (this.client) {
|
||||
await this.client.destroy();
|
||||
// Recreate fresh client for next time
|
||||
this.client = new WebTorrent();
|
||||
}
|
||||
} catch (error) {
|
||||
|
13
src/views/most-recent-videos.html
Normal file
13
src/views/most-recent-videos.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!-- views/most-recent-videos.html -->
|
||||
<section>
|
||||
<!-- You can have a heading if you want -->
|
||||
<h2 class="text-xl mb-4 text-gray-700">Most Recent Videos</h2>
|
||||
|
||||
<!-- Video List -->
|
||||
<div
|
||||
id="videoList"
|
||||
class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"
|
||||
>
|
||||
<!-- Videos will be dynamically inserted here -->
|
||||
</div>
|
||||
</section>
|
Reference in New Issue
Block a user