mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2025-09-09 15:38:44 +00:00
update
This commit is contained in:
727
src/js/app.js
Normal file
727
src/js/app.js
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
3
src/js/config.js
Normal 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
8676
src/js/libs/nostr.bundle.js
Normal file
File diff suppressed because it is too large
Load Diff
296
src/js/main.js
296
src/js/main.js
@@ -1,296 +0,0 @@
|
||||
import WebTorrent from 'https://esm.sh/webtorrent'
|
||||
|
||||
const client = new WebTorrent()
|
||||
|
||||
function log(msg) {
|
||||
console.log(msg)
|
||||
}
|
||||
|
||||
// Check if running in Brave browser
|
||||
async function isBrave() {
|
||||
return (navigator.brave?.isBrave && await navigator.brave.isBrave()) || false
|
||||
}
|
||||
|
||||
// Longer timeout for Brave
|
||||
const TIMEOUT_DURATION = 60000 // 60 seconds
|
||||
|
||||
const torrentId = 'magnet:?xt=urn:btih:a92964583d6bd03b5a420a474a96f2b47d19fd43&dn=SeedSigner_SingleSig_Guide.mp4&tr=udp%3A%2F%2Ftracker.leechers-paradise.org%3A6969&tr=udp%3A%2F%2Ftracker.coppersurfer.tk%3A6969&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337&tr=udp%3A%2F%2Fexplodie.org%3A6969&tr=udp%3A%2F%2Ftracker.empire-js.us%3A1337&tr=wss%3A%2F%2Ftracker.btorrent.xyz&tr=wss%3A%2F%2Ftracker.openwebtorrent.com'
|
||||
|
||||
async function waitForServiceWorkerActivation(registration) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Service worker activation timeout'))
|
||||
}, TIMEOUT_DURATION)
|
||||
|
||||
log('Waiting for service worker activation...')
|
||||
|
||||
const checkActivation = () => {
|
||||
if (registration.active) {
|
||||
clearTimeout(timeout)
|
||||
log('Service worker is active')
|
||||
resolve(registration)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check immediately
|
||||
if (checkActivation()) return
|
||||
|
||||
// Set up activation listener
|
||||
registration.addEventListener('activate', () => {
|
||||
checkActivation()
|
||||
})
|
||||
|
||||
// Handle waiting state
|
||||
if (registration.waiting) {
|
||||
log('Service worker is waiting, sending skip waiting message')
|
||||
registration.waiting.postMessage({ type: 'SKIP_WAITING' })
|
||||
}
|
||||
|
||||
// Additional state change listener
|
||||
registration.addEventListener('statechange', () => {
|
||||
checkActivation()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function setupServiceWorker() {
|
||||
try {
|
||||
// Check for service worker support
|
||||
if (!('serviceWorker' in navigator) || !navigator.serviceWorker) {
|
||||
throw new Error('Service Worker not supported or disabled')
|
||||
}
|
||||
|
||||
// Brave-specific initialization
|
||||
if (await isBrave()) {
|
||||
log('Brave browser detected')
|
||||
// Force clear any existing registrations in Brave
|
||||
const registrations = await navigator.serviceWorker.getRegistrations()
|
||||
for (const registration of registrations) {
|
||||
await registration.unregister()
|
||||
}
|
||||
// Add delay for Brave's privacy checks
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
|
||||
// Get current path for service worker scope
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.substring(0, currentPath.lastIndexOf('/') + 1)
|
||||
|
||||
// Register service worker with explicit scope
|
||||
log('Registering service worker...')
|
||||
const registration = await navigator.serviceWorker.register('./sw.min.js', {
|
||||
scope: basePath,
|
||||
updateViaCache: 'none'
|
||||
})
|
||||
log('Service worker registered')
|
||||
|
||||
// Wait for installation
|
||||
if (registration.installing) {
|
||||
log('Waiting for installation...')
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
reject(new Error('Installation timeout'))
|
||||
}, TIMEOUT_DURATION)
|
||||
|
||||
registration.installing.addEventListener('statechange', (e) => {
|
||||
log('Service worker state:', e.target.state)
|
||||
if (e.target.state === 'activated' || e.target.state === 'redundant') {
|
||||
clearTimeout(timeout)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Wait for activation
|
||||
await waitForServiceWorkerActivation(registration)
|
||||
log('Service worker activated')
|
||||
|
||||
// Wait for ready state
|
||||
const readyRegistration = await Promise.race([
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Service worker ready timeout')), TIMEOUT_DURATION)
|
||||
)
|
||||
])
|
||||
|
||||
if (!readyRegistration.active) {
|
||||
throw new Error('Service worker not active after ready state')
|
||||
}
|
||||
|
||||
log('Service worker ready')
|
||||
return registration
|
||||
} catch (error) {
|
||||
log('Service worker setup error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function 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]}`
|
||||
}
|
||||
|
||||
function startTorrent() {
|
||||
log('Starting torrent download')
|
||||
client.add(torrentId, torrent => {
|
||||
const status = document.querySelector('#status')
|
||||
const progress = document.querySelector('#progress')
|
||||
const video = document.querySelector('#video')
|
||||
const peers = document.querySelector('#peers')
|
||||
const speed = document.querySelector('#speed')
|
||||
const downloaded = document.querySelector('#downloaded')
|
||||
|
||||
log('Torrent added: ' + torrent.name)
|
||||
status.textContent = `Loading ${torrent.name}...`
|
||||
|
||||
const file = torrent.files.find(file => file.name.endsWith('.mp4'))
|
||||
if (!file) {
|
||||
log('No MP4 file found in torrent')
|
||||
status.textContent = 'Error: No video file found'
|
||||
return
|
||||
}
|
||||
|
||||
// Set up video element
|
||||
video.muted = true
|
||||
video.crossOrigin = 'anonymous'
|
||||
|
||||
// Enhanced video error handling
|
||||
video.addEventListener('error', (e) => {
|
||||
const error = e.target.error
|
||||
log('Video error:', error)
|
||||
if (error) {
|
||||
log('Error code:', error.code)
|
||||
log('Error message:', error.message)
|
||||
}
|
||||
status.textContent = 'Error playing video. Try disabling Brave Shields for this site.'
|
||||
})
|
||||
|
||||
video.addEventListener('canplay', () => {
|
||||
const playPromise = video.play()
|
||||
if (playPromise !== undefined) {
|
||||
playPromise
|
||||
.then(() => log('Autoplay started'))
|
||||
.catch(err => {
|
||||
log('Autoplay failed:', err)
|
||||
status.textContent = 'Click to play video'
|
||||
// Add click-to-play handler
|
||||
video.addEventListener('click', () => {
|
||||
video.play()
|
||||
.then(() => log('Play started by user'))
|
||||
.catch(err => log('Play failed:', err))
|
||||
}, { once: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Handle metadata loading
|
||||
video.addEventListener('loadedmetadata', () => {
|
||||
log('Video metadata loaded')
|
||||
if (video.duration === Infinity || isNaN(video.duration)) {
|
||||
log('Invalid duration, attempting to fix...')
|
||||
video.currentTime = 1e101
|
||||
video.currentTime = 0
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
file.streamTo(video)
|
||||
log('Streaming started')
|
||||
} catch (error) {
|
||||
log('Streaming error:', error)
|
||||
status.textContent = 'Error starting video stream'
|
||||
}
|
||||
|
||||
// Update stats every second
|
||||
const statsInterval = setInterval(() => {
|
||||
if (!document.body.contains(video)) {
|
||||
clearInterval(statsInterval)
|
||||
return
|
||||
}
|
||||
|
||||
const percentage = torrent.progress * 100
|
||||
progress.style.width = `${percentage}%`
|
||||
peers.textContent = `Peers: ${torrent.numPeers}`
|
||||
speed.textContent = `${formatBytes(torrent.downloadSpeed)}/s`
|
||||
downloaded.textContent = `${formatBytes(torrent.downloaded)} / ${formatBytes(torrent.length)}`
|
||||
|
||||
if (torrent.progress === 1) {
|
||||
status.textContent = `${torrent.name}`
|
||||
} else {
|
||||
status.textContent = `Loading ${torrent.name}...`
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
torrent.on('error', err => {
|
||||
log('Torrent error:', err)
|
||||
status.textContent = 'Error loading video'
|
||||
clearInterval(statsInterval)
|
||||
})
|
||||
|
||||
// Cleanup handler
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(statsInterval)
|
||||
client.destroy()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const isBraveBrowser = await isBrave()
|
||||
|
||||
// Check for secure context
|
||||
if (!window.isSecureContext) {
|
||||
throw new Error('HTTPS or localhost required')
|
||||
}
|
||||
|
||||
// Brave-specific checks
|
||||
if (isBraveBrowser) {
|
||||
log('Checking Brave configuration...')
|
||||
|
||||
// Check if service workers are enabled
|
||||
if (!navigator.serviceWorker) {
|
||||
throw new Error('Please enable Service Workers in Brave Shield settings')
|
||||
}
|
||||
|
||||
// Check for WebRTC
|
||||
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||
throw new Error('Please enable WebRTC in Brave Shield settings')
|
||||
}
|
||||
}
|
||||
|
||||
log('Setting up service worker...')
|
||||
const registration = await setupServiceWorker()
|
||||
|
||||
if (!registration || !registration.active) {
|
||||
throw new Error('Service worker setup failed')
|
||||
}
|
||||
|
||||
log('Service worker activated and ready')
|
||||
|
||||
// Create WebTorrent server with activated service worker
|
||||
client.createServer({ controller: registration })
|
||||
log('WebTorrent server created')
|
||||
|
||||
// Start the torrent
|
||||
startTorrent()
|
||||
} catch (error) {
|
||||
log('Initialization error:', error)
|
||||
const status = document.querySelector('#status')
|
||||
if (status) {
|
||||
const errorMessage = await isBrave()
|
||||
? `${error.message} (Try disabling Brave Shields for this site)`
|
||||
: error.message
|
||||
status.textContent = 'Error initializing: ' + errorMessage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Start everything when page loads
|
||||
window.addEventListener('load', init)
|
328
src/js/nostr.js
Normal file
328
src/js/nostr.js
Normal 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
286
src/js/webtorrent.js
Normal 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()
|
Reference in New Issue
Block a user