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