This commit is contained in:
2025-01-05 23:51:52 -05:00
parent 6a3143bda2
commit 0a78cb57a0
20 changed files with 20624 additions and 62 deletions

241
src copy/css/style.css Normal file
View File

@@ -0,0 +1,241 @@
/* css/style.css */
/* Global Colors */
:root {
--color-bg: #1a1a2e;
--color-primary: #9b59b6; /* Purple */
--color-secondary: #e74c3c; /* Red */
--color-text: #ecf0f1; /* Light gray */
--color-muted: #95a5a6; /* Muted gray */
}
/* General Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
margin: 0;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
/* Header */
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
color: var(--color-primary);
}
header p {
font-size: 1.1rem;
color: var(--color-muted);
}
/* Buttons */
button {
background-color: var(--color-primary);
color: var(--color-text);
border: none;
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: var(--color-secondary);
}
button:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Forms */
form input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-muted);
border-radius: 5px;
background-color: var(--color-bg);
color: var(--color-text);
margin-bottom: 1rem;
font-size: 1rem;
}
form input:focus {
border-color: var(--color-primary);
outline: none;
}
/* Video List */
#videoList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background-color: #24243e;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.3s;
display: flex;
flex-direction: column;
}
.video-card:hover {
transform: translateY(-5px);
}
.video-card img {
width: 100%;
display: block;
}
.video-card .details {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.video-card h3 {
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.video-card button {
width: 100%;
margin-top: 1rem;
}
/* Highlight dev mode videos */
.video-card.border-red-500 {
border: 2px solid red;
}
.video-card p.text-red-500 {
color: var(--color-secondary) !important;
}
.video-card p.text-green-500 {
color: #2ecc71; /* Green */
}
/* Player Modal */
#playerModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: none; /* Hidden by default */
justify-content: center;
align-items: center;
padding: 1rem;
z-index: 1000;
}
#playerModal.flex {
display: flex; /* Show modal as flex when active */
}
#playerModal .content {
background-color: var(--color-bg);
border-radius: 10px;
padding: 2rem;
max-width: 800px;
width: 100%;
color: var(--color-text);
position: relative; /* Ensure Close button is positioned correctly */
}
#closePlayer {
background-color: var(--color-secondary); /* Use secondary color for Close button */
color: var(--color-text);
border: none;
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
#closePlayer:hover {
background-color: var(--color-primary); /* Swap colors on hover for contrast */
}
#closePlayer:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Notification Containers */
#errorContainer, #successContainer {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
padding: 1rem;
}
#errorContainer {
background-color: #f8d7da;
color: #721c24;
}
#successContainer {
background-color: #d4edda;
color: #155724;
}
/* Responsive Design */
@media (max-width: 768px) {
header h1 {
font-size: 2rem;
}
header p {
font-size: 1rem;
}
button {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
form input {
font-size: 0.9rem;
padding: 0.4rem;
}
.video-card h3 {
font-size: 1rem;
}
}
/* Tailwind's hidden class */
.hidden {
display: none;
}

View File

@@ -0,0 +1,70 @@
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
max-width: 800px;
margin: 20px auto;
padding: 0 20px;
background: #1a1a1a;
color: #ffffff;
}
video {
width: 100%;
max-width: 800px;
margin: 20px 0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
background: #000;
}
.info-container {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.progress-bar {
background: #3a3a3a;
height: 6px;
border-radius: 3px;
overflow: hidden;
margin: 10px 0;
}
.progress-bar-fill {
background: #2196F3;
height: 100%;
width: 0;
transition: width 0.3s ease;
border-radius: 3px;
}
.stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #aaa;
margin-top: 8px;
}
.status {
color: #2196F3;
font-size: 14px;
margin-bottom: 8px;
}
h1 {
color: #fff;
font-size: 24px;
font-weight: 500;
}
.peers {
display: flex;
gap: 15px;
}
.speed {
color: #4CAF50;
}

32
src copy/demo/demo.html Normal file
View File

@@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebTorrent Video Demo</title>
<link rel="stylesheet" href="./css/style.css">
</head>
<body>
<h1>WebTorrent Video Demo</h1>
<!-- Video player -->
<video id="video" controls></video>
<!-- Info Container -->
<div class="info-container">
<div class="status" id="status">Initializing...</div>
<div class="progress-bar">
<div class="progress-bar-fill" id="progress"></div>
</div>
<div class="stats">
<div class="peers">
<span id="peers">Peers: 0</span>
<span class="speed" id="speed">0 KB/s</span>
</div>
<span id="downloaded">0 MB / 0 MB</span>
</div>
</div>
<script type="module" src="js/main.js"></script>
</body>
</html>

132
src copy/demo/sw.min.js vendored Normal file
View File

@@ -0,0 +1,132 @@
(() => {
"use strict";
let cancelled = false;
// Handle skip waiting message
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Immediately install and activate
self.addEventListener("install", () => {
self.skipWaiting()
})
// Claim clients on activation
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([
clients.claim(),
self.skipWaiting(),
caches.keys().then(cacheNames =>
Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)))
)
])
)
})
// Handle fetch events
self.addEventListener("fetch", s => {
const t = (s => {
const { url: t } = s.request;
// Only handle webtorrent requests
if (!t.includes(self.registration.scope + "webtorrent/")) {
return null;
}
// Handle keepalive requests
if (t.includes(self.registration.scope + "webtorrent/keepalive/")) {
return new Response();
}
// Handle cancel requests
if (t.includes(self.registration.scope + "webtorrent/cancel/")) {
return new Response(new ReadableStream({
cancel() {
cancelled = true;
}
}));
}
// Handle streaming requests
return async function({ request: s }) {
const { url: t, method: n, headers: o, destination: a } = s;
// Get all window clients
const l = await clients.matchAll({
type: "window",
includeUncontrolled: true
});
// Create message channel and wait for response
const [r, i] = await new Promise(e => {
for (const s of l) {
const l = new MessageChannel,
{ port1: r, port2: i } = l;
r.onmessage = ({ data: s }) => {
e([s, r])
};
s.postMessage({
url: t,
method: n,
headers: Object.fromEntries(o.entries()),
scope: self.registration.scope,
destination: a,
type: "webtorrent"
}, [i]);
}
});
let c = null;
const d = () => {
i.postMessage(false);
clearTimeout(c);
i.onmessage = null;
};
// Handle non-streaming response
if (r.body !== "STREAM") {
d();
return new Response(r.body, r);
}
// Handle streaming response
return new Response(new ReadableStream({
pull: s => new Promise(t => {
i.onmessage = ({ data: e }) => {
if (e) {
s.enqueue(e);
} else {
d();
s.close();
}
t();
};
if (!cancelled && a !== "document") {
clearTimeout(c);
c = setTimeout(() => {
d();
t();
}, 5000);
}
i.postMessage(true);
}),
cancel() {
d();
}
}), r);
}(s);
})(s);
if (t) {
s.respondWith(t);
}
});
})();

103
src copy/index.html Normal file
View File

@@ -0,0 +1,103 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NosTube MVP</title>
<!-- TailwindCSS CDN -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="css/style.css" rel="stylesheet">
</head>
<body class="bg-gray-100">
<div id="app" class="container mx-auto px-4 py-8">
<!-- Header -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-800">NosTube</h1>
<p class="text-gray-600">Decentralized video sharing</p>
<!-- Demo Link -->
<div class="mt-4">
<a href="./demo/demo.html" class="text-blue-500 hover:underline">
View Demo Page
</a>
</div>
</header>
<!-- Login Section -->
<div id="loginSection" class="mb-8 flex items-center justify-between">
<div>
<button id="loginButton" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Login with Nostr
</button>
<p id="userStatus" class="mt-4 text-gray-500 hidden">Logged in as: <span id="userPubKey"></span></p>
</div>
<div>
<button id="logoutButton" class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 hidden">
Logout
</button>
</div>
</div>
<!-- Error Container -->
<div id="errorContainer" class="hidden bg-red-100 text-red-800 p-4 rounded-md mb-4">
<!-- Error messages will appear here -->
</div>
<!-- Success Container -->
<div id="successContainer" class="hidden bg-green-100 text-green-800 p-4 rounded-md mb-4">
<!-- Success messages will appear here -->
</div>
<!-- Video Submission Form -->
<div class="bg-white p-6 rounded-lg shadow-md mb-8 hidden" id="videoFormContainer">
<h2 class="text-xl font-semibold mb-4">Share a Video</h2>
<form id="submitForm" class="space-y-4">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="title" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div>
<label for="magnet" class="block text-sm font-medium text-gray-700">Magnet Link</label>
<input type="text" id="magnet" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div>
<label for="thumbnail" class="block text-sm font-medium text-gray-700">Thumbnail URL (optional)</label>
<input type="url" id="thumbnail"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<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 List -->
<div id="videoList" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<!-- Videos will be inserted here -->
</div>
<!-- Video Player Modal -->
<div id="playerModal" class="hidden fixed inset-0 bg-black bg-opacity-50">
<div class="bg-white p-4 rounded-lg max-w-4xl w-full mx-4 relative">
<div id="player"></div>
<button id="closePlayer" class="mt-4 px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 absolute top-2 right-2">
Close
</button>
</div>
</div>
</div>
<!-- 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 type="module" src="js/config.js"></script>
<script type="module" src="js/webtorrent.js"></script>
<script type="module" src="js/nostr.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>

341
src copy/js/app.js Normal file
View File

@@ -0,0 +1,341 @@
// js/app.js
import { nostrClient } from './nostr.js';
import { torrentClient } from './webtorrent.js';
import { isDevMode } from './config.js';
class NosTubeApp {
constructor() {
this.loginButton = document.getElementById('loginButton');
this.logoutButton = document.getElementById('logoutButton'); // Added logout button
this.userStatus = document.getElementById('userStatus');
this.userPubKey = document.getElementById('userPubKey');
this.submitForm = document.getElementById('submitForm');
this.playerModal = document.getElementById('playerModal');
this.player = document.getElementById('player');
this.videoList = document.getElementById('videoList');
this.closePlayerBtn = document.getElementById('closePlayer');
this.errorContainer = document.getElementById('errorContainer');
this.successContainer = document.getElementById('successContainer');
this.videoFormContainer = document.getElementById('videoFormContainer'); // Added form container
this.pubkey = null;
}
/**
* Initializes the application by setting up the Nostr client and loading videos.
*/
async init() {
try {
// Ensure the modal is hidden by default
this.playerModal.classList.add('hidden');
// Hide the video submission form initially
this.videoFormContainer.classList.add('hidden');
// Initialize Nostr client
await nostrClient.init();
console.log('Nostr client initialized.');
// Check if user is already logged in (e.g., from localStorage)
const savedPubKey = localStorage.getItem('userPubKey');
if (savedPubKey) {
this.login(savedPubKey, false); // Do not prompt for login again
}
// Setup event listeners
this.setupEventListeners();
console.log('Event listeners set up.');
// Load videos
await this.loadVideos();
console.log('Videos loaded.');
} catch (error) {
console.error('Failed to initialize app:', error);
this.showError('Failed to connect to Nostr relay. Please try again later.');
}
}
/**
* Sets up event listeners for login, logout, form submission, and modal interactions.
*/
setupEventListeners() {
// Login Button
this.loginButton.addEventListener('click', async () => {
try {
const pubkey = await nostrClient.login();
this.login(pubkey, true);
} catch (error) {
console.error('Login failed:', error);
this.showError('Failed to login. Please try again.');
}
});
// Logout Button
this.logoutButton.addEventListener('click', () => {
this.logout();
});
// Form submission
this.submitForm.addEventListener('submit', (e) => this.handleSubmit(e));
// Close player modal
if (this.closePlayerBtn) {
this.closePlayerBtn.addEventListener('click', () => {
console.log('Close button clicked. Hiding modal...');
this.hideModal();
});
} else {
console.error('Close button not found!');
}
// Close modal when clicking outside the modal content
if (this.playerModal) {
this.playerModal.addEventListener('click', (e) => {
if (e.target === this.playerModal) {
console.log('Clicked outside modal content. Hiding modal...');
this.hideModal();
}
});
} else {
console.error('playerModal not found!');
}
}
/**
* Handles user login by updating UI elements.
* @param {string} pubkey - The public key of the logged-in user.
* @param {boolean} saveToStorage - Whether to save the pubkey to localStorage.
*/
login(pubkey, saveToStorage = true) {
this.pubkey = pubkey;
this.loginButton.classList.add('hidden');
this.logoutButton.classList.remove('hidden');
this.userStatus.classList.remove('hidden');
this.userPubKey.textContent = pubkey;
this.videoFormContainer.classList.remove('hidden'); // Show form
console.log(`User logged in as: ${pubkey}`);
if (saveToStorage) {
localStorage.setItem('userPubKey', pubkey);
}
}
/**
* Logs out the user by resetting UI elements and internal state.
*/
logout() {
nostrClient.logout();
this.pubkey = null;
this.loginButton.classList.remove('hidden');
this.logoutButton.classList.add('hidden');
this.userStatus.classList.add('hidden');
this.userPubKey.textContent = '';
this.videoFormContainer.classList.add('hidden'); // Hide form
localStorage.removeItem('userPubKey');
console.log('User logged out.');
}
/**
* Hides the player modal, clears the player content, and stops streaming.
*/
async hideModal() {
if (this.playerModal) {
this.playerModal.classList.add('hidden');
this.playerModal.classList.remove('flex');
console.log('Modal hidden.');
} else {
console.error('playerModal is undefined.');
}
if (this.player) {
this.player.innerHTML = ''; // Clear video content when modal is closed
console.log('Player content cleared.');
} else {
console.error('player is undefined.');
}
try {
await torrentClient.stopStreaming();
console.log('Streaming stopped.');
} catch (error) {
console.error('Error stopping streaming:', error.message);
}
}
/**
* Handles the submission of a new video.
* @param {Event} e - The form submission event.
*/
async handleSubmit(e) {
e.preventDefault();
if (!this.pubkey) {
this.showError('Please login to post a video.');
return;
}
const formData = {
title: document.getElementById('title').value.trim(),
magnet: document.getElementById('magnet').value.trim(),
thumbnail: document.getElementById('thumbnail').value.trim(),
mode: isDevMode ? 'dev' : 'live', // Add mode to the metadata
};
// Basic client-side validation
if (!formData.title || !formData.magnet) {
this.showError('Title and Magnet URI are required.');
return;
}
try {
await nostrClient.publishVideo(formData, this.pubkey);
this.submitForm.reset();
await this.loadVideos(); // Refresh video list
this.showSuccess('Video shared successfully!');
} catch (error) {
console.error('Failed to publish video:', error.message);
this.showError('Failed to share video. Please try again later.');
}
}
/**
* Loads videos from the relays and renders them.
*/
async loadVideos() {
try {
const videos = await nostrClient.fetchVideos();
if (videos.length === 0) {
console.log('No valid videos found.');
}
this.renderVideoList(videos);
} catch (error) {
console.error('Failed to fetch videos:', error.message);
this.showError('An error occurred while loading videos. Please try again later.');
}
}
/**
* Renders the list of videos in the UI.
* @param {Array} videos - An array of video objects to render.
*/
renderVideoList(videos) {
if (videos.length === 0) {
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available yet. Be the first to upload one!</p>';
return;
}
// Sort videos by creation date (newest first)
videos.sort((a, b) => b.created_at - a.created_at);
this.videoList.innerHTML = videos.map(video => `
<div class="video-card ${video.mode === 'dev' ? 'border border-red-500' : ''}">
<div class="aspect-w-16 aspect-h-9 bg-gray-100">
${video.thumbnail ?
`<img src="${this.escapeHTML(video.thumbnail)}" alt="${this.escapeHTML(video.title)}" class="object-cover w-full h-48">` :
'<div class="flex items-center justify-center h-48 bg-gray-200">No thumbnail</div>'
}
</div>
<div class="details p-4">
<h3 class="text-lg font-semibold mb-2">${this.escapeHTML(video.title)}</h3>
<p class="text-sm ${video.mode === 'dev' ? 'text-red-500' : 'text-green-500'}">
${video.mode.toUpperCase()}
</p>
<button
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')"
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"
>
Play Video
</button>
</div>
</div>
`).join('');
}
/**
* Plays the selected video using the torrent client.
* @param {string} magnetURI - The Magnet URI of the video to play.
*/
async playVideo(magnetURI) {
if (!magnetURI) {
this.showError('Invalid Magnet URI.');
return;
}
console.log('Opening video modal...');
this.playerModal.classList.remove('hidden');
this.playerModal.classList.add('flex');
console.log('Modal opened for video playback.');
try {
await torrentClient.streamVideo(decodeURIComponent(magnetURI), this.player);
} catch (error) {
console.error('Failed to play video:', error.message);
this.showError('Failed to play video. Please try again.');
this.hideModal();
}
}
/**
* Displays an error message to the user.
* @param {string} message - The error message to display.
*/
showError(message) {
if (this.errorContainer) {
this.errorContainer.textContent = message;
this.errorContainer.classList.remove('hidden');
console.warn(`Error displayed to user: ${message}`);
// Hide the error message after 5 seconds
setTimeout(() => {
this.errorContainer.classList.add('hidden');
this.errorContainer.textContent = '';
}, 5000);
} else {
console.warn('Error container not found. Falling back to alert.');
alert(message); // Fallback for missing error container
}
}
/**
* Displays a success message to the user.
* @param {string} message - The success message to display.
*/
showSuccess(message) {
if (this.successContainer) {
this.successContainer.textContent = message;
this.successContainer.classList.remove('hidden');
console.log(`Success message displayed: ${message}`);
// Hide the success message after 5 seconds
setTimeout(() => {
this.successContainer.classList.add('hidden');
this.successContainer.textContent = '';
}, 5000);
} else {
console.log('Success container not found. Falling back to alert.');
alert(message); // Fallback for missing success container
}
}
/**
* Escapes HTML characters to prevent XSS attacks.
* @param {string} unsafe - The string to escape.
* @returns {string} The escaped string.
*/
escapeHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
}
// Initialize app
const app = new NosTubeApp();
app.init();
// Make playVideo accessible globally for the onclick handlers
window.app = app;

3
src copy/js/config.js Normal file
View File

@@ -0,0 +1,3 @@
// js/config.js
export const isDevMode = true; // Set to false for production

File diff suppressed because it is too large Load Diff

227
src copy/js/nostr.js Normal file
View File

@@ -0,0 +1,227 @@
// js/nostr.js
import { isDevMode } from './config.js';
const RELAY_URLS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://nostr.wine'
];
class NostrClient {
constructor() {
this.pool = new window.NostrTools.SimplePool(); // Access via window
this.pubkey = null;
this.relays = RELAY_URLS;
}
/**
* Initializes the Nostr client by connecting to relays.
*/
async init() {
try {
console.log('Connecting to relays...');
// Test relay connections
const testFilter = { kinds: [0], limit: 1 }; // Dummy filter for testing
const connections = this.relays.map(async url => {
try {
const sub = this.pool.sub([url], [testFilter]);
sub.on('event', event => console.log(`Test event from ${url}:`, event));
sub.on('eose', () => {
console.log(`Relay ${url} connected successfully.`);
sub.unsub();
});
return { url, success: true };
} catch (err) {
console.error(`Failed to connect to relay: ${url}`, err.message);
return { url, success: false };
}
});
const results = await Promise.all(connections);
const successfulRelays = results.filter(r => r.success).map(r => r.url);
if (successfulRelays.length === 0) {
throw new Error('No relays could be connected.');
}
console.log(`Connected to ${successfulRelays.length} relay(s):`, successfulRelays);
} catch (err) {
console.error('Failed to initialize Nostr client:', err.message);
throw err;
}
}
/**
* Logs in the user using a Nostr extension or by entering an NSEC key.
* @returns {Promise<string>} The public key of the logged-in user.
*/
async login() {
if (window.nostr) {
try {
const pubkey = await window.nostr.getPublicKey();
this.pubkey = pubkey;
console.log('Logged in with extension. Public key:', this.pubkey);
return this.pubkey;
} catch (e) {
console.warn('Failed to get public key from Nostr extension:', e.message);
throw new Error('Failed to get public key from Nostr extension.');
}
} else {
const nsec = prompt('Enter your NSEC key:');
if (nsec) {
try {
this.pubkey = this.decodeNsec(nsec);
console.log('Logged in with NSEC. Public key:', this.pubkey);
return this.pubkey;
} catch (error) {
console.error('Invalid NSEC key:', error.message);
throw new Error('Invalid NSEC key.');
}
} else {
throw new Error('Login cancelled or NSEC key not provided.');
}
}
}
/**
* Logs out the user by clearing the public key.
*/
logout() {
this.pubkey = null;
console.log('User logged out.');
}
/**
* Decodes an NSEC key to retrieve the public key.
* @param {string} nsec - The NSEC key.
* @returns {string} The corresponding public key.
*/
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec); // Access via window
return data;
} catch (error) {
throw new Error('Invalid NSEC key.');
}
}
/**
* Publishes a new video event to all relays.
* @param {Object} videoData - The video data to publish.
* @param {string} pubkey - The public key of the user publishing the video.
* @returns {Promise<Object>} The signed event.
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) {
throw new Error('User is not logged in.');
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [['t', 'video']],
content: JSON.stringify(videoData)
};
try {
const signedEvent = await window.nostr.signEvent(event);
await Promise.all(this.relays.map(async url => {
try {
await this.pool.publish([url], signedEvent);
console.log(`Event published to ${url}`);
} catch (err) {
console.error(`Failed to publish to ${url}:`, err.message);
}
}));
return signedEvent;
} catch (error) {
console.error('Failed to sign event:', error.message);
throw new Error('Failed to sign event.');
}
}
/**
* Fetches videos from all configured relays, ensuring that only valid video events are included.
* Filters videos based on the current mode (dev or live).
* @returns {Promise<Array>} An array of valid video objects.
*/
async fetchVideos() {
const filter = {
kinds: [30078], // Video kind
limit: 50 // Fetch up to 50 videos
};
const videos = new Map(); // Use a Map to ensure unique videos by event ID
// Initialize summary counters
const invalidEventSummary = {
malformedJson: 0,
invalidFormat: 0
};
// Fetch videos from all relays
await Promise.all(
this.relays.map(async url => {
try {
const events = await this.pool.list([url], [filter]);
events.forEach(event => {
try {
const content = JSON.parse(event.content);
// Filter by mode
if (content.mode === (isDevMode ? 'dev' : 'live') && this.isValidVideo(content)) {
if (!videos.has(event.id)) { // Ensure uniqueness
videos.set(event.id, {
id: event.id,
title: content.title,
magnet: content.magnet,
thumbnail: content.thumbnail || '',
mode: content.mode,
pubkey: event.pubkey,
created_at: event.created_at,
});
}
}
} catch (jsonError) {
invalidEventSummary.malformedJson++;
console.error(
`Failed to parse video content from ${url}: ${jsonError.message} | Event ID: ${event.id}`
);
}
});
} catch (relayError) {
console.error(`Failed to fetch videos from relay ${url}: ${relayError.message}`);
}
})
);
// Log a summary of issues
if (invalidEventSummary.malformedJson > 0) {
console.warn(`Skipped ${invalidEventSummary.malformedJson} event(s) due to malformed JSON.`);
}
if (invalidEventSummary.invalidFormat > 0) {
console.warn(`Skipped ${invalidEventSummary.invalidFormat} event(s) due to invalid format.`);
}
return Array.from(videos.values()); // Return unique videos as an array
}
/**
* Validates the structure of a video content object.
* @param {Object} content - The content object to validate.
* @returns {boolean} True if valid, false otherwise.
*/
isValidVideo(content) {
return (
typeof content === 'object' &&
typeof content.title === 'string' &&
typeof content.magnet === 'string' &&
typeof content.mode === 'string' &&
(typeof content.thumbnail === 'string' || typeof content.thumbnail === 'undefined')
);
}
}
// Export the client
export const nostrClient = new NostrClient();

122
src copy/js/webtorrent.js Normal file
View File

@@ -0,0 +1,122 @@
// js/webtorrent.js
export class TorrentClient {
constructor() {
this.client = new WebTorrent();
this.currentTorrent = null;
// Handle client-level errors
this.client.on('error', (err) => {
console.error('WebTorrent client error:', err.message);
// Optionally, emit events or handle errors globally
});
}
/**
* Streams a video from a given Magnet URI into a specified HTML element.
* @param {string} magnetURI - The Magnet URI of the torrent.
* @param {HTMLElement} playerElement - The HTML element where the video will be rendered.
* @returns {Promise<void>} Resolves when streaming starts successfully.
*/
streamVideo(magnetURI, playerElement) {
return new Promise((resolve, reject) => {
if (!magnetURI) {
reject(new Error('Magnet URI is required.'));
return;
}
console.log(`Adding torrent: ${magnetURI}`);
// If there's an existing torrent, remove it first
if (this.currentTorrent) {
console.log('Removing existing torrent before adding a new one.');
this.client.remove(this.currentTorrent, (err) => {
if (err) {
console.error('Error removing existing torrent:', err.message);
// Proceed to add the new torrent even if removal fails
}
this._addTorrent(magnetURI, playerElement, resolve, reject);
});
} else {
this._addTorrent(magnetURI, playerElement, resolve, reject);
}
});
}
/**
* Adds a torrent and streams the video.
* @private
* @param {string} magnetURI - The Magnet URI of the torrent.
* @param {HTMLElement} playerElement - The HTML element where the video will be rendered.
* @param {Function} resolve - The resolve function of the Promise.
* @param {Function} reject - The reject function of the Promise.
*/
_addTorrent(magnetURI, playerElement, resolve, reject) {
this.client.add(magnetURI, (torrent) => {
this.currentTorrent = torrent;
console.log('Torrent metadata received:', torrent.infoHash);
// Find the first compatible video file in the torrent
const file = torrent.files.find(file => {
return file.name.endsWith('.mp4') ||
file.name.endsWith('.webm') ||
file.name.endsWith('.mkv');
});
if (!file) {
console.error('No compatible video file found in the torrent.');
reject(new Error('No compatible video file found in the torrent.'));
return;
}
console.log('Streaming file:', file.name);
// Use renderTo for better compatibility and simplicity
file.renderTo(playerElement, { autoplay: true, controls: true }, (err, elem) => {
if (err) {
console.error('Error rendering video:', err);
reject(err);
} else {
console.log('Video rendered successfully.');
resolve();
}
});
});
// Handle torrent-specific errors
this.client.on('torrent', (torrent) => {
torrent.on('error', (err) => {
console.error(`Torrent error (${torrent.infoHash}):`, err.message);
reject(err);
});
});
}
/**
* Stops streaming the current torrent and cleans up resources.
* @returns {Promise<void>} Resolves when the torrent is successfully removed.
*/
stopStreaming() {
return new Promise((resolve, reject) => {
if (this.currentTorrent) {
console.log('Removing current torrent:', this.currentTorrent.infoHash);
this.client.remove(this.currentTorrent, (err) => {
if (err) {
console.error('Error removing torrent:', err.message);
reject(err);
} else {
console.log('Torrent removed successfully.');
this.currentTorrent = null;
resolve();
}
});
} else {
console.warn('No active torrent to stop.');
resolve(); // Nothing to do
}
});
}
}
// Export an instance of TorrentClient
export const torrentClient = new TorrentClient();

132
src copy/sw.min.js vendored Normal file
View File

@@ -0,0 +1,132 @@
(() => {
"use strict";
let cancelled = false;
// Handle skip waiting message
self.addEventListener('message', event => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting()
}
})
// Immediately install and activate
self.addEventListener("install", () => {
self.skipWaiting()
})
// Claim clients on activation
self.addEventListener('activate', event => {
event.waitUntil(
Promise.all([
clients.claim(),
self.skipWaiting(),
caches.keys().then(cacheNames =>
Promise.all(cacheNames.map(cacheName => caches.delete(cacheName)))
)
])
)
})
// Handle fetch events
self.addEventListener("fetch", s => {
const t = (s => {
const { url: t } = s.request;
// Only handle webtorrent requests
if (!t.includes(self.registration.scope + "webtorrent/")) {
return null;
}
// Handle keepalive requests
if (t.includes(self.registration.scope + "webtorrent/keepalive/")) {
return new Response();
}
// Handle cancel requests
if (t.includes(self.registration.scope + "webtorrent/cancel/")) {
return new Response(new ReadableStream({
cancel() {
cancelled = true;
}
}));
}
// Handle streaming requests
return async function({ request: s }) {
const { url: t, method: n, headers: o, destination: a } = s;
// Get all window clients
const l = await clients.matchAll({
type: "window",
includeUncontrolled: true
});
// Create message channel and wait for response
const [r, i] = await new Promise(e => {
for (const s of l) {
const l = new MessageChannel,
{ port1: r, port2: i } = l;
r.onmessage = ({ data: s }) => {
e([s, r])
};
s.postMessage({
url: t,
method: n,
headers: Object.fromEntries(o.entries()),
scope: self.registration.scope,
destination: a,
type: "webtorrent"
}, [i]);
}
});
let c = null;
const d = () => {
i.postMessage(false);
clearTimeout(c);
i.onmessage = null;
};
// Handle non-streaming response
if (r.body !== "STREAM") {
d();
return new Response(r.body, r);
}
// Handle streaming response
return new Response(new ReadableStream({
pull: s => new Promise(t => {
i.onmessage = ({ data: e }) => {
if (e) {
s.enqueue(e);
} else {
d();
s.close();
}
t();
};
if (!cancelled && a !== "document") {
clearTimeout(c);
c = setTimeout(() => {
d();
t();
}, 5000);
}
i.postMessage(true);
}),
cancel() {
d();
}
}), r);
}(s);
})(s);
if (t) {
s.respondWith(t);
}
});
})();

View File

@@ -1,70 +1,397 @@
/* css/style.css */
/* Global Colors */
:root {
--color-bg: #1a1a2e;
--color-primary: #9b59b6; /* Purple */
--color-secondary: #e74c3c; /* Red */
--color-text: #ecf0f1; /* Light gray */
--color-muted: #95a5a6; /* Muted gray */
}
/* General Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif;
max-width: 800px;
margin: 20px auto;
padding: 0 20px;
background: #1a1a1a;
color: #ffffff;
font-family: 'Arial', sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
line-height: 1.6;
margin: 0;
}
video {
.container {
max-width: 1200px;
margin: 0 auto;
padding: 1rem;
}
/* Header */
header {
text-align: center;
margin-bottom: 2rem;
}
header h1 {
font-size: 2.5rem;
color: var(--color-primary);
}
header p {
font-size: 1.1rem;
color: var(--color-muted);
}
/* Buttons */
button {
background-color: var(--color-primary);
color: var(--color-text);
border: none;
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
button:hover {
background-color: var(--color-secondary);
}
button:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Forms */
form input {
width: 100%;
max-width: 800px;
margin: 20px 0;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
background: #000;
padding: 0.5rem;
border: 1px solid var(--color-muted);
border-radius: 5px;
background-color: var(--color-bg);
color: var(--color-text);
margin-bottom: 1rem;
font-size: 1rem;
}
.info-container {
background: #2a2a2a;
padding: 15px;
border-radius: 8px;
margin: 15px 0;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
form input:focus {
border-color: var(--color-primary);
outline: none;
}
/* Video List */
#videoList {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
}
.video-card {
background-color: #24243e;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
transition: transform 0.3s;
display: flex;
flex-direction: column;
}
.video-card:hover {
transform: translateY(-5px);
}
.video-card img {
width: 100%;
display: block;
}
.video-card .details {
padding: 1rem;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.video-card h3 {
font-size: 1.2rem;
color: var(--color-primary);
margin-bottom: 0.5rem;
}
.video-card button {
width: 100%;
margin-top: 1rem;
}
/* Highlight dev mode videos */
.video-card.border-red-500 {
border: 2px solid red;
}
.video-card p.text-red-500 {
color: var(--color-secondary) !important;
}
.video-card p.text-green-500 {
color: #2ecc71; /* Green */
}
/* Player Section */
#playerSection {
background-color: #24243e;
padding: 1rem;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
#playerSection video {
width: 100%;
border-radius: 10px;
}
.progress-bar {
background: #3a3a3a;
height: 6px;
border-radius: 3px;
width: 100%;
background-color: #4a4a4a;
border-radius: 9999px;
overflow: hidden;
margin: 10px 0;
height: 8px;
margin-bottom: 0.5rem;
}
.progress-bar-fill {
background: #2196F3;
height: 100%;
width: 0;
transition: width 0.3s ease;
border-radius: 3px;
background-color: var(--color-primary);
width: 0%;
transition: width 1s ease-in-out;
}
.stats {
display: flex;
justify-content: space-between;
font-size: 14px;
color: #aaa;
margin-top: 8px;
font-size: 0.9rem;
color: var(--color-muted);
}
.status {
color: #2196F3;
font-size: 14px;
margin-bottom: 8px;
/* Video Player Modal */
#playerModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
display: none; /* Hidden by default */
justify-content: center;
align-items: center;
padding: 1rem;
z-index: 1000;
}
h1 {
color: #fff;
font-size: 24px;
font-weight: 500;
#playerModal .bg-white {
background-color: #24243e;
padding: 1rem;
border-radius: 10px;
position: relative;
width: 90%;
max-width: 800px;
}
.peers {
#playerModal video {
width: 100%;
border-radius: 10px;
}
#playerModal .progress-bar {
width: 100%;
background-color: #4a4a4a;
border-radius: 9999px;
overflow: hidden;
height: 8px;
margin-top: 0.5rem;
}
#playerModal .progress-bar-fill {
height: 100%;
background-color: var(--color-primary);
width: 0%;
transition: width 1s ease-in-out;
}
#playerModal .stats {
display: flex;
gap: 15px;
justify-content: space-between;
font-size: 0.9rem;
color: var(--color-muted);
}
.speed {
color: #4CAF50;
#closePlayer {
background-color: var(--color-secondary);
color: var(--color-text);
border: none;
padding: 0.5rem 1rem;
font-size: 1rem;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.3s;
}
#closePlayer:hover {
background-color: var(--color-primary);
}
#closePlayer:focus {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
/* Notification Containers */
#errorContainer, #successContainer {
display: flex;
align-items: center;
justify-content: center;
border-radius: 5px;
padding: 1rem;
}
#errorContainer {
background-color: #f8d7da;
color: #721c24;
}
#successContainer {
background-color: #d4edda;
color: #155724;
}
/* Responsive Design */
@media (max-width: 768px) {
header h1 {
font-size: 2rem;
}
header p {
font-size: 1rem;
}
button {
font-size: 0.9rem;
padding: 0.4rem 0.8rem;
}
form input {
font-size: 0.9rem;
padding: 0.4rem;
}
.video-card h3 {
font-size: 1rem;
}
#playerSection video, #playerModal video {
height: auto;
}
}
/* Tailwind's hidden class */
.hidden {
display: none;
}
/* Updated Video Card Styles */
.video-card {
transition: all 0.3s ease;
background-color: #1a1a2e;
}
.video-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
}
.video-card img.thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
/* Modal Styles */
#playerModal {
backdrop-filter: blur(5px);
}
#playerModal .modal-content {
background-color: #1a1a2e;
max-height: 90vh;
overflow-y: auto;
}
#playerModal video {
background-color: #000;
}
/* Aspect Ratio Container */
.aspect-w-16 {
position: relative;
padding-bottom: 56.25%; /* 16:9 Aspect Ratio */
}
.aspect-w-16 > * {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
object-fit: cover;
}
/* Stats Container */
.stats-container {
background-color: rgba(26, 26, 46, 0.8);
backdrop-filter: blur(5px);
border-radius: 0.5rem;
}
/* Progress Bar Animation */
.progress-bar-fill {
transition: width 0.3s ease;
}
#videoList {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
padding: 1rem;
}
@media (max-width: 640px) {
#videoList {
grid-template-columns: 1fr;
gap: 1rem;
}
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
}
.video-card {
transition: all 0.3s ease;
}
.video-card:hover {
transform: translateY(-2px);
}
.video-card h3 {
line-height: 1.4;
}

View File

@@ -3,30 +3,166 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebTorrent Video Demo</title>
<link rel="stylesheet" href="./css/style.css">
<title>NosTube MVP</title>
<!-- TailwindCSS CDN -->
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
<link href="css/style.css" rel="stylesheet">
</head>
<body>
<h1>WebTorrent Video Demo</h1>
<!-- Video player -->
<video id="video" controls></video>
<!-- Info Container -->
<div class="info-container">
<div class="status" id="status">Initializing...</div>
<div class="progress-bar">
<div class="progress-bar-fill" id="progress"></div>
</div>
<div class="stats">
<div class="peers">
<span id="peers">Peers: 0</span>
<span class="speed" id="speed">0 KB/s</span>
<body class="bg-gray-100">
<div id="app" class="container mx-auto px-4 py-8">
<!-- Header -->
<header class="mb-8">
<h1 class="text-3xl font-bold text-gray-800">NosTube</h1>
<p class="text-gray-600">Decentralized video sharing</p>
</header>
<!-- Login Section -->
<div id="loginSection" class="mb-8 flex items-center justify-between">
<div>
<button id="loginButton" class="bg-blue-500 text-white px-4 py-2 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
Login with Nostr
</button>
<p id="userStatus" class="mt-4 text-gray-500 hidden">Logged in as: <span id="userPubKey"></span></p>
</div>
<span id="downloaded">0 MB / 0 MB</span>
<div>
<button id="logoutButton" class="bg-red-500 text-white px-4 py-2 rounded-md hover:bg-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 hidden">
Logout
</button>
</div>
</div>
<!-- Error Container -->
<div id="errorContainer" class="hidden bg-red-100 text-red-800 p-4 rounded-md mb-4">
<!-- Error messages will appear here -->
</div>
<!-- Success Container -->
<div id="successContainer" class="hidden bg-green-100 text-green-800 p-4 rounded-md mb-4">
<!-- Success messages will appear here -->
</div>
<!-- Video Submission Form -->
<div class="bg-white p-6 rounded-lg shadow-md mb-8 hidden" id="videoFormContainer">
<h2 class="text-xl font-semibold mb-4">Share a Video</h2>
<form id="submitForm" class="space-y-4">
<div>
<label for="title" class="block text-sm font-medium text-gray-700">Title</label>
<input type="text" id="title" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div>
<label for="magnet" class="block text-sm font-medium text-gray-700">Magnet Link</label>
<input type="text" id="magnet" required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<div>
<label for="thumbnail" class="block text-sm font-medium text-gray-700">Thumbnail URL (optional)</label>
<input type="url" id="thumbnail"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500">
</div>
<!-- Description Field -->
<div>
<label for="description" class="block text-sm font-medium text-gray-700">Description (optional)</label>
<textarea id="description" rows="3"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
<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...</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>
<script type="module" src="js/main.js"></script>
<!-- Improved Video Player Modal -->
<div id="playerModal" class="hidden fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 p-4">
<div class="bg-gray-900 rounded-lg max-w-4xl w-full relative overflow-hidden">
<!-- Close button -->
<button id="closePlayer" class="absolute top-4 right-4 z-50 text-white bg-gray-800 hover:bg-gray-700 rounded-full p-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" 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>
<!-- Video container -->
<div class="aspect-w-16 aspect-h-9">
<video id="modalVideo" controls class="w-full rounded-t-lg"></video>
</div>
<!-- Video info section -->
<div class="p-6 text-white">
<!-- Video Title -->
<h2 id="videoTitle" class="text-2xl font-bold mb-2"></h2>
<!-- Video Timestamp -->
<div class="flex items-center justify-between text-sm text-gray-400 mb-4">
<span id="videoTimestamp">just now</span>
<div id="modalStatus" class="text-gray-300">Initializing...</div>
</div>
<!-- Creator info -->
<div class="flex items-center mb-4 p-4 bg-gray-800 rounded-lg">
<div id="creatorAvatar" class="w-12 h-12 rounded-full bg-gray-700 overflow-hidden">
<img src="" alt="Creator" class="w-full h-full object-cover">
</div>
<div class="ml-4">
<h3 id="creatorName" class="font-medium text-lg">Creator Name</h3>
<p id="creatorNpub" class="text-sm text-gray-400">npub...</p>
</div>
</div>
<!-- Video Description -->
<div class="bg-gray-800 rounded-lg p-4 mb-4">
<p id="videoDescription" class="text-gray-300 whitespace-pre-wrap">No description available.</p>
</div>
<!-- Torrent stats -->
<div class="bg-gray-800 rounded-lg p-4">
<div class="w-full bg-gray-700 rounded-full h-2 mb-2">
<div class="bg-blue-500 h-2 rounded-full" id="modalProgress" style="width: 0%"></div>
</div>
<div class="flex justify-between text-sm text-gray-400">
<span id="modalPeers">Peers: 0</span>
<span id="modalSpeed">Speed: 0 KB/s</span>
<span id="modalDownloaded">Downloaded: 0 MB / 0 MB</span>
</div>
</div>
</div>
</div>
</div>
<!-- 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 type="module" src="js/config.js"></script>
<script type="module" src="js/webtorrent.js"></script>
<script type="module" src="js/nostr.js"></script>
<script type="module" src="js/app.js"></script>
</body>
</html>
</html>

727
src/js/app.js Normal file
View File

@@ -0,0 +1,727 @@
// js/app.js
import { nostrClient } from './nostr.js';
import { torrentClient } from './webtorrent.js';
import { isDevMode } from './config.js';
class NosTubeApp {
constructor() {
// Authentication Elements
this.loginButton = document.getElementById('loginButton');
this.logoutButton = document.getElementById('logoutButton');
this.userStatus = document.getElementById('userStatus');
this.userPubKey = document.getElementById('userPubKey');
// Form Elements
this.submitForm = document.getElementById('submitForm');
this.videoFormContainer = document.getElementById('videoFormContainer');
// Video List Element
this.videoList = document.getElementById('videoList');
// Video Player Elements
this.playerSection = document.getElementById('playerSection');
this.videoElement = document.getElementById('video');
this.status = document.getElementById('status');
this.progressBar = document.getElementById('progress');
this.peers = document.getElementById('peers');
this.speed = document.getElementById('speed');
this.downloaded = document.getElementById('downloaded');
// Modal Elements
this.playerModal = document.getElementById('playerModal');
this.modalVideo = document.getElementById('modalVideo');
this.modalStatus = document.getElementById('modalStatus');
this.modalProgress = document.getElementById('modalProgress');
this.modalPeers = document.getElementById('modalPeers');
this.modalSpeed = document.getElementById('modalSpeed');
this.modalDownloaded = document.getElementById('modalDownloaded');
this.closePlayerBtn = document.getElementById('closePlayer');
// Video Info Elements
this.videoTitle = document.getElementById('videoTitle');
this.videoDescription = document.getElementById('videoDescription');
this.videoTimestamp = document.getElementById('videoTimestamp');
// Creator Info Elements
this.creatorAvatar = document.getElementById('creatorAvatar').querySelector('img');
this.creatorName = document.getElementById('creatorName');
this.creatorNpub = document.getElementById('creatorNpub');
// Notification Containers
this.errorContainer = document.getElementById('errorContainer');
this.successContainer = document.getElementById('successContainer');
this.pubkey = null;
this.currentMagnetUri = null;
}
/**
* Initializes the application by setting up the Nostr client and loading videos.
*/
async init() {
try {
// Hide the video player sections initially
this.playerSection.classList.add('hidden');
this.playerModal.classList.add('hidden');
// Initialize Nostr client
await nostrClient.init();
this.log('Nostr client initialized.');
// Check if user is already logged in
const savedPubKey = localStorage.getItem('userPubKey');
if (savedPubKey) {
this.login(savedPubKey, false);
}
// Setup event listeners
this.setupEventListeners();
this.log('Event listeners set up.');
// Load videos
await this.loadVideos();
this.log('Videos loaded.');
} catch (error) {
this.log('Failed to initialize app:', error);
this.showError('Failed to connect to Nostr relay. Please try again later.');
}
}
/**
* Formats a timestamp into a "time ago" format.
*/
formatTimeAgo(timestamp) {
const seconds = Math.floor((Date.now() / 1000) - timestamp);
const intervals = {
year: 31536000,
month: 2592000,
week: 604800,
day: 86400,
hour: 3600,
minute: 60
};
for (const [unit, secondsInUnit] of Object.entries(intervals)) {
const interval = Math.floor(seconds / secondsInUnit);
if (interval >= 1) {
return `${interval} ${unit}${interval === 1 ? '' : 's'} ago`;
}
}
return 'just now';
}
/**
* Sets up event listeners for various UI interactions.
*/
setupEventListeners() {
// Login Button
this.loginButton.addEventListener('click', async () => {
try {
const pubkey = await nostrClient.login();
this.login(pubkey, true);
} catch (error) {
this.log('Login failed:', error);
this.showError('Failed to login. Please try again.');
}
});
// Logout Button
this.logoutButton.addEventListener('click', () => {
this.logout();
});
// Form submission
this.submitForm.addEventListener('submit', (e) => this.handleSubmit(e));
// Close Modal Button
if (this.closePlayerBtn) {
this.closePlayerBtn.addEventListener('click', async () => {
await this.hideModal();
});
}
// Close Modal by clicking outside content
if (this.playerModal) {
this.playerModal.addEventListener('click', async (e) => {
if (e.target === this.playerModal) {
await this.hideModal();
}
});
}
// Video error handling
this.videoElement.addEventListener('error', (e) => {
const error = e.target.error;
this.log('Video error:', error);
if (error) {
this.showError(`Video playback error: ${error.message || 'Unknown error'}`);
}
});
// Detailed Modal Video Event Listeners
if (this.modalVideo) {
// Add detailed video error logging
this.modalVideo.addEventListener('error', (e) => {
const error = e.target.error;
this.log('Modal video error:', error);
if (error) {
this.log('Error code:', error.code);
this.log('Error message:', error.message);
this.showError(`Video playback error: ${error.message || 'Unknown error'}`);
}
});
this.modalVideo.addEventListener('loadstart', () => {
this.log('Video loadstart event fired');
});
this.modalVideo.addEventListener('loadedmetadata', () => {
this.log('Video loadedmetadata event fired');
});
this.modalVideo.addEventListener('canplay', () => {
this.log('Video canplay event fired');
});
}
// Cleanup on page unload
window.addEventListener('beforeunload', async () => {
await this.cleanup();
});
}
/**
* Handles user login.
*/
login(pubkey, saveToStorage = true) {
this.pubkey = pubkey;
this.loginButton.classList.add('hidden');
this.logoutButton.classList.remove('hidden');
this.userStatus.classList.remove('hidden');
this.userPubKey.textContent = pubkey;
this.videoFormContainer.classList.remove('hidden');
this.log(`User logged in as: ${pubkey}`);
if (saveToStorage) {
localStorage.setItem('userPubKey', pubkey);
}
}
/**
* Handles user logout.
*/
logout() {
nostrClient.logout();
this.pubkey = null;
this.loginButton.classList.remove('hidden');
this.logoutButton.classList.add('hidden');
this.userStatus.classList.add('hidden');
this.userPubKey.textContent = '';
this.videoFormContainer.classList.add('hidden');
localStorage.removeItem('userPubKey');
this.log('User logged out.');
}
/**
* Cleans up video player and torrents.
*/
async cleanup() {
try {
if (this.videoElement) {
this.videoElement.pause();
this.videoElement.src = '';
this.videoElement.load();
}
if (this.modalVideo) {
this.modalVideo.pause();
this.modalVideo.src = '';
this.modalVideo.load();
}
await torrentClient.cleanup();
} catch (error) {
this.log('Cleanup error:', error);
}
}
/**
* Hides the video player section.
*/
async hideVideoPlayer() {
await this.cleanup();
this.playerSection.classList.add('hidden');
}
/**
* Hides the video modal.
*/
async hideModal() {
await this.cleanup();
this.playerModal.style.display = 'none';
this.playerModal.classList.add('hidden');
}
/**
* Handles video submission.
*/
async handleSubmit(e) {
e.preventDefault();
if (!this.pubkey) {
this.showError('Please login to post a video.');
return;
}
const descriptionElement = document.getElementById('description');
const formData = {
title: document.getElementById('title') ? document.getElementById('title').value.trim() : '',
magnet: document.getElementById('magnet') ? document.getElementById('magnet').value.trim() : '',
thumbnail: document.getElementById('thumbnail') ? document.getElementById('thumbnail').value.trim() : '',
description: descriptionElement ? descriptionElement.value.trim() : '',
mode: isDevMode ? 'dev' : 'live',
};
// Debugging Log: Check formData
this.log('Form Data Collected:', formData);
if (!formData.title || !formData.magnet) {
this.showError('Title and Magnet URI are required.');
return;
}
try {
await nostrClient.publishVideo(formData, this.pubkey);
this.submitForm.reset();
await this.loadVideos();
this.showSuccess('Video shared successfully!');
} catch (error) {
this.log('Failed to publish video:', error.message);
this.showError('Failed to share video. Please try again later.');
}
}
/**
* Loads and displays videos from Nostr.
*/
async loadVideos() {
try {
const videos = await nostrClient.fetchVideos();
this.log('Fetched videos (raw):', videos);
// Log detailed type info
this.log('Videos type:', typeof videos);
this.log('Is Array:', Array.isArray(videos), 'Length:', videos?.length);
if (!videos) {
this.log('No videos received');
throw new Error('No videos received from relays');
}
// Convert to array if it isn't one
const videosArray = Array.isArray(videos) ? videos : [videos];
this.log('Processing videos array:', JSON.stringify(videosArray, null, 2));
if (videosArray.length === 0) {
this.log('No valid videos found.');
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available yet. Be the first to upload one!</p>';
return;
}
// Log each video object before rendering
videosArray.forEach((video, index) => {
this.log(`Video ${index} details:`, {
id: video.id,
title: video.title,
magnet: video.magnet,
mode: video.mode,
pubkey: video.pubkey,
created_at: video.created_at,
hasTitle: Boolean(video.title),
hasMagnet: Boolean(video.magnet),
hasMode: Boolean(video.mode)
});
});
this.renderVideoList(videosArray);
this.log(`Rendered ${videosArray.length} videos successfully`);
} catch (error) {
this.log('Failed to fetch videos:', error);
this.log('Error details:', {
name: error.name,
message: error.message,
stack: error.stack
});
this.showError('An error occurred while loading videos. Please try again later.');
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available at the moment. Please try again later.</p>';
}
}
/**
* Renders the video list in the UI.
*/
async renderVideoList(videos) {
try {
this.log('Starting renderVideoList with videos:', JSON.stringify(videos));
if (!videos || videos.length === 0) {
this.log('No videos to render');
this.videoList.innerHTML = '<p class="text-center text-gray-500">No videos available yet. Be the first to upload one!</p>';
return;
}
// Sort videos by creation date (newest first)
videos.sort((a, b) => b.created_at - a.created_at);
// Fetch usernames and profile pictures for all pubkeys
const userProfiles = new Map();
const uniquePubkeys = [...new Set(videos.map(v => v.pubkey))];
for (const pubkey of uniquePubkeys) {
try {
const userEvents = await nostrClient.pool.list(nostrClient.relays, [{
kinds: [0],
authors: [pubkey],
limit: 1
}]);
if (userEvents[0]?.content) {
const profile = JSON.parse(userEvents[0].content);
userProfiles.set(pubkey, {
name: profile.name || profile.display_name || 'Unknown',
picture: profile.picture || `https://robohash.org/${pubkey}`
});
} else {
// Fallback if no profile found
userProfiles.set(pubkey, {
name: 'Unknown',
picture: `https://robohash.org/${pubkey}`
});
}
} catch (error) {
this.log(`Error fetching profile for ${pubkey}:`, error);
// Fallback in case of error
userProfiles.set(pubkey, {
name: 'Unknown',
picture: `https://robohash.org/${pubkey}`
});
}
}
// Convert hex pubkeys to npubs
const getNpub = (pubkey) => {
try {
return window.NostrTools.nip19.npubEncode(pubkey);
} catch {
return pubkey;
}
};
const renderedVideos = videos.map((video, index) => {
try {
if (!this.validateVideo(video, index)) {
return '';
}
const profile = userProfiles.get(video.pubkey) || { name: 'Unknown', picture: `https://robohash.org/${video.pubkey}` };
const npub = getNpub(video.pubkey);
const displayName = profile.name || `${npub.slice(0, 8)}...${npub.slice(-4)}`;
const timeAgo = this.formatTimeAgo(video.created_at);
return `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300">
<div class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')">
${video.thumbnail ?
`<img src="${this.escapeHTML(video.thumbnail)}"
alt="${this.escapeHTML(video.title)}"
class="w-full h-full object-cover">` :
`<div class="flex items-center justify-center h-full bg-gray-800">
<svg class="w-16 h-16 text-gray-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>`
}
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
</div>
<div class="p-4">
<div class="flex space-x-3">
<div class="flex-shrink-0">
<div class="w-10 h-10 rounded-full bg-gray-700 overflow-hidden">
<img src="${this.escapeHTML(profile.picture)}" alt="${displayName}" class="w-full h-full object-cover">
</div>
</div>
<div class="flex-1 min-w-0">
<h3 class="text-base font-medium text-white mb-1 line-clamp-2 hover:text-blue-400 cursor-pointer"
onclick="app.playVideo('${encodeURIComponent(video.magnet)}')">
${this.escapeHTML(video.title)}
</h3>
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
${this.escapeHTML(displayName)}
</p>
<div class="flex items-center text-xs text-gray-400 mt-1">
<span>${timeAgo}</span>
<span class="mx-1">•</span>
<span class="${video.mode === 'dev' ? 'text-red-400' : 'text-green-400'}">
${video.mode.toUpperCase()}
</span>
</div>
</div>
</div>
</div>
</div>
`;
} catch (error) {
this.log(`Error processing video ${index}:`, error);
return '';
}
}).filter(html => html.length > 0);
if (renderedVideos.length === 0) {
this.videoList.innerHTML = '<p class="text-center text-gray-500">No valid videos available.</p>';
return;
}
this.videoList.innerHTML = renderedVideos.join('');
this.log('Rendered video list successfully');
} catch (error) {
this.log('Error in renderVideoList:', error);
this.showError('Failed to render video list. Please try again later.');
this.videoList.innerHTML = '<p class="text-center text-gray-500">Error loading videos. Please try again later.</p>';
}
}
/**
* Formats a Nostr public key into a shortened npub format
*/
formatNpub(pubkey) {
if (!pubkey) return 'Unknown';
try {
// Format the pubkey to show only first 6 and last 4 characters
return `${pubkey.slice(0, 6)}...${pubkey.slice(-4)}`;
} catch (error) {
return 'Unknown';
}
}
/**
* Validates a video object
*/
validateVideo(video, index) {
const validationResults = {
hasVideo: Boolean(video),
hasTitle: Boolean(video?.title),
hasMagnet: Boolean(video?.magnet),
hasMode: Boolean(video?.mode),
hasPubkey: Boolean(video?.pubkey),
isValidTitle: typeof video?.title === 'string' && video.title.length > 0,
isValidMagnet: typeof video?.magnet === 'string' && video.magnet.length > 0
};
this.log(`Video ${index} validation results:`, validationResults);
return Object.values(validationResults).every(Boolean);
}
/**
* Gets a user-friendly error message.
*/
getErrorMessage(error) {
if (error.message.includes('404')) {
return 'Service worker not found. Please check server configuration.';
} else if (error.message.includes('Brave')) {
return 'Please disable Brave Shields for this site to play videos.';
} else if (error.message.includes('timeout')) {
return 'Connection timeout. Please check your internet connection.';
} else {
return 'Failed to play video. Please try again.';
}
}
/**
* Shows an error message to the user.
*/
showError(message) {
if (this.errorContainer) {
this.errorContainer.textContent = message;
this.errorContainer.classList.remove('hidden');
setTimeout(() => {
this.errorContainer.classList.add('hidden');
this.errorContainer.textContent = '';
}, 5000);
} else {
alert(message);
}
}
/**
* Shows a success message to the user.
*/
showSuccess(message) {
if (this.successContainer) {
this.successContainer.textContent = message;
this.successContainer.classList.remove('hidden');
setTimeout(() => {
this.successContainer.classList.add('hidden');
this.successContainer.textContent = '';
}, 5000);
} else {
alert(message);
}
}
/**
* Escapes HTML to prevent XSS.
*/
escapeHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
/**
* Logs messages to console.
*/
log(message) {
console.log(message);
}
/**
* Plays a video given its magnet URI.
* This method handles the logic to initiate torrent download and play the video.
*/
async playVideo(magnetURI) {
try {
if (!magnetURI) {
this.showError('Invalid Magnet URI.');
return;
}
// Decode the magnet URI
const decodedMagnet = decodeURIComponent(magnetURI);
// Don't restart if it's the same video
if (this.currentMagnetUri === decodedMagnet) {
this.log('Same video requested - already playing');
return;
}
// Store current magnet URI
this.currentMagnetUri = decodedMagnet;
// Show the modal first
this.playerModal.style.display = 'flex';
this.playerModal.classList.remove('hidden');
// Find the video data
const videos = await nostrClient.fetchVideos();
const video = videos.find(v => v.magnet === decodedMagnet);
if (!video) {
this.showError('Video data not found.');
return;
}
// Fetch creator profile
let creatorProfile = { name: 'Unknown', picture: `https://robohash.org/${video.pubkey}` };
try {
const userEvents = await nostrClient.pool.list(nostrClient.relays, [{
kinds: [0],
authors: [video.pubkey],
limit: 1
}]);
if (userEvents[0]?.content) {
const profile = JSON.parse(userEvents[0].content);
creatorProfile = {
name: profile.name || profile.display_name || 'Unknown',
picture: profile.picture || `https://robohash.org/${video.pubkey}`
};
}
} catch (error) {
this.log('Error fetching creator profile:', error);
}
// Convert pubkey to npub
let creatorNpub = 'Unknown';
try {
creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey);
} catch (error) {
this.log('Error converting pubkey to npub:', error);
creatorNpub = video.pubkey;
}
// Update video info
this.videoTitle.textContent = video.title || 'Untitled';
this.videoDescription.textContent = video.description || 'No description available.';
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
// Update creator info
this.creatorName.textContent = creatorProfile.name;
this.creatorNpub.textContent = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(-4)}`;
this.creatorAvatar.src = creatorProfile.picture;
this.creatorAvatar.alt = creatorProfile.name;
// Start streaming
this.log('Starting video stream:', decodedMagnet);
await torrentClient.streamVideo(decodedMagnet, this.modalVideo);
// Update UI elements based on existing DOM elements that webtorrent.js updates
const updateInterval = setInterval(() => {
// Check if modal is still visible
if (!document.body.contains(this.modalVideo)) {
clearInterval(updateInterval);
return;
}
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) this.modalStatus.textContent = status.textContent;
if (progress) this.modalProgress.style.width = progress.style.width;
if (peers) this.modalPeers.textContent = peers.textContent;
if (speed) this.modalSpeed.textContent = speed.textContent;
if (downloaded) this.modalDownloaded.textContent = downloaded.textContent;
}, 1000);
} catch (error) {
this.log('Error in playVideo:', error);
this.showError(`Playback error: ${error.message}`);
}
}
/**
* Updates the UI with the current torrent status.
*/
updateTorrentStatus(torrent) {
if (!torrent) return;
this.modalStatus.textContent = torrent.status;
this.modalProgress.style.width = `${(torrent.progress * 100).toFixed(2)}%`;
this.modalPeers.textContent = `Peers: ${torrent.numPeers}`;
this.modalSpeed.textContent = `${(torrent.downloadSpeed / 1024).toFixed(2)} KB/s`;
this.modalDownloaded.textContent = `${(torrent.downloaded / (1024 * 1024)).toFixed(2)} MB / ${(torrent.length / (1024 * 1024)).toFixed(2)} MB`;
// Update periodically
if (torrent.ready) {
this.modalStatus.textContent = 'Ready to play';
} else {
setTimeout(() => this.updateTorrentStatus(torrent), 1000);
}
}
}
// Initialize app
const app = new NosTubeApp();
app.init();
// Make playVideo accessible globally for the onclick handlers
window.app = app;

3
src/js/config.js Normal file
View File

@@ -0,0 +1,3 @@
// js/config.js
export const isDevMode = true; // Set to false for production

8676
src/js/libs/nostr.bundle.js Normal file

File diff suppressed because it is too large Load Diff

328
src/js/nostr.js Normal file
View File

@@ -0,0 +1,328 @@
// js/nostr.js
import { isDevMode } from './config.js';
const RELAY_URLS = [
'wss://relay.damus.io',
'wss://nos.lol',
'wss://relay.snort.social',
'wss://nostr.wine'
];
// 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.');
}
}
class NostrClient {
constructor() {
this.pool = null; // Initialize to null, we'll create it in init()
this.pubkey = null;
this.relays = RELAY_URLS;
}
/**
* Initializes the Nostr client by connecting to relays.
*/
async init() {
try {
if (isDevMode) console.log('Connecting to relays...');
// Initialize the pool
this.pool = new window.NostrTools.SimplePool();
// Test relay connections
const testFilter = { kinds: [0], limit: 1 }; // Dummy filter for testing
const connections = this.relays.map(async url => {
try {
return new Promise((resolve) => {
const sub = this.pool.sub([url], [testFilter]);
// Set a timeout for connection attempts
let timeout = setTimeout(() => {
sub.unsub();
if (isDevMode) console.log(`Connection timeout for ${url}`);
resolve({ url, success: false });
}, 5000);
sub.on('event', () => {
clearTimeout(timeout);
sub.unsub();
if (isDevMode) console.log(`Received event from ${url}`);
resolve({ url, success: true });
});
sub.on('eose', () => {
clearTimeout(timeout);
sub.unsub();
if (isDevMode) console.log(`EOSE from ${url}`);
resolve({ url, success: true });
});
});
} catch (err) {
if (isDevMode) console.error(`Failed to connect to relay: ${url}`, err.message);
return { url, success: false };
}
});
const results = await Promise.all(connections);
const successfulRelays = results.filter(r => r.success).map(r => r.url);
if (successfulRelays.length === 0) {
throw new Error('No relays could be connected.');
}
if (isDevMode) console.log(`Connected to ${successfulRelays.length} relay(s):`, successfulRelays);
} catch (err) {
console.error('Failed to initialize Nostr client:', err.message);
throw err;
}
}
/**
* Logs in the user using a Nostr extension or by entering an NSEC key.
*/
async login() {
if (window.nostr) {
try {
const pubkey = await window.nostr.getPublicKey();
this.pubkey = pubkey;
if (isDevMode) console.log('Logged in with extension. Public key:', this.pubkey);
return this.pubkey;
} catch (e) {
if (isDevMode) console.warn('Failed to get public key from Nostr extension:', e.message);
throw new Error('Failed to get public key from Nostr extension.');
}
} else {
const nsec = prompt('Enter your NSEC key:');
if (nsec) {
try {
this.pubkey = this.decodeNsec(nsec);
if (isDevMode) console.log('Logged in with NSEC. Public key:', this.pubkey);
return this.pubkey;
} catch (error) {
if (isDevMode) console.error('Invalid NSEC key:', error.message);
throw new Error('Invalid NSEC key.');
}
} else {
throw new Error('Login cancelled or NSEC key not provided.');
}
}
}
/**
* Logs out the user.
*/
logout() {
this.pubkey = null;
if (isDevMode) console.log('User logged out.');
}
/**
* Decodes an NSEC key.
*/
decodeNsec(nsec) {
try {
const { data } = window.NostrTools.nip19.decode(nsec);
return data;
} catch (error) {
throw new Error('Invalid NSEC key.');
}
}
/**
* Publishes a new video event to all relays.
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) {
throw new Error('User is not logged in.');
}
// Debugging Log: Check videoData
if (isDevMode) {
console.log('Publishing video with data:', videoData);
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [['t', 'video']], // Include the 't=video' tag
content: JSON.stringify(videoData) // videoData should include description
};
// Debugging Log: Check stringified content
if (isDevMode) {
console.log('Event content after stringify:', event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
// Debugging Log: Check signed 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.');
}
}
/**
* Fetches videos from all configured relays.
*/
async fetchVideos() {
// Filter for all videos tagged with 't=video'
const filter = {
kinds: [30078],
'#t': ['video'],
limit: 500
};
console.log('Fetching videos with filter:', filter);
const videos = new Map();
try {
await Promise.all(
this.relays.map(async url => {
console.log(`Querying ${url}...`);
try {
const sub = this.pool.sub([url], [filter]);
await new Promise((resolve) => {
const timeout = setTimeout(() => {
sub.unsub();
console.warn(`Timeout querying ${url}`);
resolve();
}, 10000); // 10 seconds timeout
sub.on('event', event => {
console.log(`Received event from ${url}:`, {
id: event.id,
created_at: new Date(event.created_at * 1000).toISOString(),
pubkey: event.pubkey,
content: event.content.substring(0, 100) + '...' // Log first 100 chars
});
try {
const content = JSON.parse(event.content);
// Save all mode videos (dev and live)
if (content.mode) {
// Check if video already exists to prevent duplicates
if (!videos.has(event.id)) {
videos.set(event.id, {
id: event.id,
title: content.title,
magnet: content.magnet,
thumbnail: content.thumbnail || '',
description: content.description || '',
mode: content.mode,
pubkey: event.pubkey,
created_at: event.created_at
});
console.log(`Added video: ${content.title} (Mode: ${content.mode})`);
} else {
console.log(`Duplicate video skipped: ${content.title}`);
}
} else {
console.log(`Skipped video (missing mode): ${content.title}`);
}
} catch (error) {
console.error(`Failed to parse event ${event.id}:`, error);
}
});
sub.on('eose', () => {
clearTimeout(timeout);
console.log(`Finished querying ${url}`);
sub.unsub();
resolve();
});
});
} catch (error) {
console.error(`Error with relay ${url}:`, error);
}
})
);
// Convert to array and sort by creation date (newest first)
const videoArray = Array.from(videos.values())
.sort((a, b) => b.created_at - a.created_at);
console.log('Found videos:', videoArray.map(v => ({
id: v.id,
title: v.title,
created_at: new Date(v.created_at * 1000).toISOString(),
mode: v.mode
})));
return videoArray;
} catch (error) {
console.error('Error fetching videos:', error);
throw error;
}
}
/**
* Validates video content structure.
*/
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') // Ensure description is optional
);
if (isDevMode && !isValid) {
console.log('Invalid video content:', content);
console.log('Validation details:', {
hasTitle: typeof content.title === 'string',
hasMagnet: typeof content.magnet === 'string',
hasMode: typeof content.mode === 'string',
validThumbnail: typeof content.thumbnail === 'string' || typeof content.thumbnail === 'undefined',
validDescription: typeof content.description === 'string' || typeof content.description === 'undefined'
});
}
return isValid;
} catch (error) {
if (isDevMode) {
console.error('Error validating video:', error);
}
return false;
}
}
}
export const nostrClient = new NostrClient();

286
src/js/webtorrent.js Normal file
View File

@@ -0,0 +1,286 @@
// js/webtorrent.js
import WebTorrent from 'https://esm.sh/webtorrent'
export class TorrentClient {
constructor() {
this.client = new WebTorrent()
this.currentTorrent = null
this.TIMEOUT_DURATION = 60000 // 60 seconds
this.statsInterval = null
}
log(msg) {
console.log(msg)
}
async isBrave() {
return (navigator.brave?.isBrave && await navigator.brave.isBrave()) || false
}
async waitForServiceWorkerActivation(registration) {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Service worker activation timeout'))
}, this.TIMEOUT_DURATION)
this.log('Waiting for service worker activation...')
const checkActivation = () => {
if (registration.active) {
clearTimeout(timeout)
this.log('Service worker is active')
resolve(registration)
return true
}
return false
}
if (checkActivation()) return
registration.addEventListener('activate', () => {
checkActivation()
})
if (registration.waiting) {
this.log('Service worker is waiting, sending skip waiting message')
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
}
registration.addEventListener('statechange', () => {
checkActivation()
})
})
}
async setupServiceWorker() {
try {
const isBraveBrowser = await this.isBrave()
if (!window.isSecureContext) {
throw new Error('HTTPS or localhost required')
}
if (!('serviceWorker' in navigator) || !navigator.serviceWorker) {
throw new Error('Service Worker not supported or disabled')
}
if (isBraveBrowser) {
this.log('Checking Brave configuration...')
if (!navigator.serviceWorker) {
throw new Error('Please enable Service Workers in Brave Shield settings')
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
throw new Error('Please enable WebRTC in Brave Shield settings')
}
const registrations = await navigator.serviceWorker.getRegistrations()
for (const registration of registrations) {
await registration.unregister()
}
await new Promise(resolve => setTimeout(resolve, 1000))
}
const currentPath = window.location.pathname
const basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1)
this.log('Registering service worker...')
const registration = await navigator.serviceWorker.register('./sw.min.js', {
scope: basePath,
updateViaCache: 'none'
})
this.log('Service worker registered')
if (registration.installing) {
this.log('Waiting for installation...')
await new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Installation timeout'))
}, this.TIMEOUT_DURATION)
registration.installing.addEventListener('statechange', (e) => {
this.log('Service worker state:', e.target.state)
if (e.target.state === 'activated' || e.target.state === 'redundant') {
clearTimeout(timeout)
resolve()
}
})
})
}
await this.waitForServiceWorkerActivation(registration)
this.log('Service worker activated')
const readyRegistration = await Promise.race([
navigator.serviceWorker.ready,
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Service worker ready timeout')), this.TIMEOUT_DURATION)
)
])
if (!readyRegistration.active) {
throw new Error('Service worker not active after ready state')
}
this.log('Service worker ready')
return registration
} catch (error) {
this.log('Service worker setup error:', error)
throw error
}
}
formatBytes(bytes) {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`
}
async streamVideo(magnetURI, videoElement) {
try {
// Setup service worker first
const registration = await this.setupServiceWorker()
if (!registration || !registration.active) {
throw new Error('Service worker setup failed')
}
// Create WebTorrent server AFTER service worker is ready
this.client.createServer({ controller: registration })
this.log('WebTorrent server created')
return new Promise((resolve, reject) => {
this.log('Starting torrent download')
this.client.add(magnetURI, torrent => {
this.log('Torrent added: ' + torrent.name)
const status = document.getElementById('status')
const progress = document.getElementById('progress')
const peers = document.getElementById('peers')
const speed = document.getElementById('speed')
const downloaded = document.getElementById('downloaded')
if (status) status.textContent = `Loading ${torrent.name}...`
const file = torrent.files.find(file =>
file.name.endsWith('.mp4') ||
file.name.endsWith('.webm') ||
file.name.endsWith('.mkv')
)
if (!file) {
const error = new Error('No compatible video file found in torrent')
this.log(error.message)
if (status) status.textContent = 'Error: No video file found'
reject(error)
return
}
videoElement.muted = true
videoElement.crossOrigin = 'anonymous'
videoElement.addEventListener('error', (e) => {
const error = e.target.error
this.log('Video error:', error)
if (error) {
this.log('Error code:', error.code)
this.log('Error message:', error.message)
}
if (status) status.textContent = 'Error playing video. Try disabling Brave Shields.'
})
videoElement.addEventListener('canplay', () => {
const playPromise = videoElement.play()
if (playPromise !== undefined) {
playPromise
.then(() => this.log('Autoplay started'))
.catch(err => {
this.log('Autoplay failed:', err)
if (status) status.textContent = 'Click to play video'
videoElement.addEventListener('click', () => {
videoElement.play()
.then(() => this.log('Play started by user'))
.catch(err => this.log('Play failed:', err))
}, { once: true })
})
}
})
videoElement.addEventListener('loadedmetadata', () => {
this.log('Video metadata loaded')
if (videoElement.duration === Infinity || isNaN(videoElement.duration)) {
this.log('Invalid duration, attempting to fix...')
videoElement.currentTime = 1e101
videoElement.currentTime = 0
}
})
try {
file.streamTo(videoElement)
this.log('Streaming started')
// Update stats every second
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
resolve()
} catch (error) {
this.log('Streaming error:', error)
if (status) status.textContent = 'Error starting video stream'
reject(error)
}
torrent.on('error', err => {
this.log('Torrent error:', err)
if (status) status.textContent = 'Error loading video'
clearInterval(this.statsInterval)
reject(err)
})
})
})
} catch (error) {
this.log('Failed to setup video streaming:', error)
throw error
}
}
async cleanup() {
try {
if (this.statsInterval) {
clearInterval(this.statsInterval)
}
if (this.currentTorrent) {
this.currentTorrent.destroy()
}
if (this.client) {
await this.client.destroy()
this.client = new WebTorrent() // Create a new client for future use
}
} catch (error) {
this.log('Cleanup error:', error)
}
}
}
export const torrentClient = new TorrentClient()