// js/app.js import { nostrClient } from './nostr.js'; import { torrentClient } from './webtorrent.js'; import { isDevMode } from './config.js'; class bitvidApp { 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; // ADDED FOR VERSIONING/PRIVATE/DELETE: // If you created an in your HTML form: this.isPrivateCheckbox = document.getElementById('isPrivate'); } /** * Initializes the application by setting up the Nostr client and loading videos. */ // app.js async init() { try { // Hide and reset player states this.playerSection.style.display = 'none'; this.playerModal.style.display = 'none'; this.currentMagnetUri = null; // Initialize Nostr and check login await nostrClient.init(); const savedPubKey = localStorage.getItem('userPubKey'); if (savedPubKey) { this.login(savedPubKey, false); } this.setupEventListeners(); await this.loadVideos(); } catch (error) { console.error('Init failed:', error); this.showError('Failed to connect to Nostr relay'); } } /** * 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) { 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 (with version, private listing). */ async handleSubmit(e) { e.preventDefault(); if (!this.pubkey) { this.showError('Please login to post a video.'); return; } const descriptionElement = document.getElementById('description'); // ADDED FOR VERSIONING/PRIVATE/DELETE: // If you have a checkbox with id="isPrivate" in HTML const isPrivate = this.isPrivateCheckbox ? this.isPrivateCheckbox.checked : false; const formData = { version: 2, // We set the version to 2 for new posts title: document.getElementById('title')?.value.trim() || '', magnet: document.getElementById('magnet')?.value.trim() || '', thumbnail: document.getElementById('thumbnail')?.value.trim() || '', description: descriptionElement?.value.trim() || '', mode: isDevMode ? 'dev' : 'live', isPrivate // new field to handle private listings }; 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(); // If the private checkbox was checked, reset it if (this.isPrivateCheckbox) { this.isPrivateCheckbox.checked = false; } 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); if (!videos) { this.log('No videos received'); throw new Error('No videos received from relays'); } // Convert to array if not already const videosArray = Array.isArray(videos) ? videos : [videos]; // **Filter** so we only show: // - isPrivate === false (public videos) // - or isPrivate === true but pubkey === this.pubkey const displayedVideos = videosArray.filter(video => { if (!video.isPrivate) { // Public video => show it return true; } // Else it's private; only show if it's owned by the logged-in user return (this.pubkey && video.pubkey === this.pubkey); }); if (displayedVideos.length === 0) { this.log('No valid videos found after filtering.'); this.videoList.innerHTML = `
No public videos available yet. Be the first to upload one!
`; return; } this.log('Processing filtered videos:', displayedVideos); displayedVideos.forEach((video, index) => { this.log(`Video ${index} details:`, { id: video.id, title: video.title, magnet: video.magnet, isPrivate: video.isPrivate, pubkey: video.pubkey, created_at: video.created_at }); }); // Now render only the displayedVideos this.renderVideoList(displayedVideos); this.log(`Rendered ${displayedVideos.length} videos successfully`); } catch (error) { this.log('Failed to fetch videos:', error); this.showError('An error occurred while loading videos. Please try again later.'); this.videoList.innerHTML = `No videos available at the moment. Please try again later.
`; } } /** * Renders the given list of videos. If a video is private and belongs to the user, * highlight with a special border (e.g. border-yellow-500). */ async renderVideoList(videos) { try { console.log('RENDER VIDEO LIST - Start', { videosReceived: videos, videosCount: videos ? videos.length : 'N/A', videosType: typeof videos }); if (!videos) { console.error('NO VIDEOS RECEIVED'); this.videoList.innerHTML = `No videos found.
`; return; } const videoArray = Array.isArray(videos) ? videos : [videos]; if (videoArray.length === 0) { console.error('VIDEO ARRAY IS EMPTY'); this.videoList.innerHTML = `No videos available.
`; return; } // Sort by creation date videoArray.sort((a, b) => b.created_at - a.created_at); // Prepare to fetch user profiles const userProfiles = new Map(); const uniquePubkeys = [...new Set(videoArray.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 { userProfiles.set(pubkey, { name: 'Unknown', picture: `https://robohash.org/${pubkey}` }); } } catch (error) { console.error(`Profile fetch error for ${pubkey}:`, error); userProfiles.set(pubkey, { name: 'Unknown', picture: `https://robohash.org/${pubkey}` }); } } // Build HTML for each video const renderedVideos = videoArray.map((video, index) => { try { if (!this.validateVideo(video, index)) { console.error(`Invalid video: ${video.title}`); return ''; } const profile = userProfiles.get(video.pubkey) || { name: 'Unknown', picture: `https://robohash.org/${video.pubkey}` }; const timeAgo = this.formatTimeAgo(video.created_at); // If user is the owner const canEdit = (video.pubkey === this.pubkey); // If it's private + user owns it => highlight with a special border const highlightClass = (video.isPrivate && canEdit) ? 'border-2 border-yellow-500' : 'border-none'; // normal case // Gear menu (unchanged) const gearMenu = canEdit ? `${this.escapeHTML(profile.name)}
No valid videos to display.
`; return; } this.videoList.innerHTML = renderedVideos.join(''); console.log('Videos rendered successfully'); } catch (error) { console.error('Rendering error:', error); this.videoList.innerHTML = `Error loading videos.
`; } } /** * Validates a video object */ validateVideo(video, index) { const validationResults = { hasId: Boolean(video?.id), isValidId: typeof video?.id === 'string' && video.id.trim().length > 0, 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, isValidMode: typeof video?.mode === 'string' && ['dev', 'live'].includes(video.mode) }; const passed = Object.values(validationResults).every(Boolean); console.log(`Video ${video?.title} validation results:`, validationResults, passed ? 'PASSED' : 'FAILED'); return passed; } /** * 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, "'"); } /** * 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; } const decodedMagnet = decodeURIComponent(magnetURI); if (this.currentMagnetUri === decodedMagnet) { this.log('Same video requested - already playing'); return; } this.currentMagnetUri = decodedMagnet; this.playerModal.style.display = 'flex'; this.playerModal.classList.remove('hidden'); // Re-fetch the latest from relays const videos = await nostrClient.fetchVideos(); const video = videos.find(v => v.magnet === decodedMagnet); if (!video) { this.showError('Video data not found.'); return; } // Decrypt only once if user owns it if (video.isPrivate && video.pubkey === this.pubkey && !video.alreadyDecrypted) { this.log('User owns a private video => decrypting magnet link...'); video.magnet = fakeDecrypt(video.magnet); // Mark it so we don't do it again video.alreadyDecrypted = true; } const finalMagnet = video.magnet; // Profile fetch 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 }]); // Ensure userEvents isn't empty before accessing [0] if (userEvents.length > 0 && 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); } let creatorNpub = 'Unknown'; try { creatorNpub = window.NostrTools.nip19.npubEncode(video.pubkey); } catch (error) { this.log('Error converting pubkey to npub:', error); creatorNpub = video.pubkey; } this.videoTitle.textContent = video.title || 'Untitled'; this.videoDescription.textContent = video.description || 'No description available.'; this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at); 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; this.log('Starting video stream with:', finalMagnet); await torrentClient.streamVideo(finalMagnet, this.modalVideo); const updateInterval = setInterval(() => { 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}`); } } 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`; if (torrent.ready) { this.modalStatus.textContent = 'Ready to play'; } else { setTimeout(() => this.updateTorrentStatus(torrent), 1000); } } /** * Allows the user to edit a video note (only if they are the owner). * We reuse the note's existing d tag via nostrClient.editVideo. */ async handleEditVideo(index) { try { const videos = await nostrClient.fetchVideos(); const video = videos[index]; if (!this.pubkey) { this.showError('Please login to edit videos.'); return; } if (video.pubkey !== this.pubkey) { this.showError('You do not own this video.'); return; } // Prompt for new fields or keep old const newTitle = prompt('New Title? (Leave blank to keep existing)', video.title); const newMagnet = prompt('New Magnet? (Leave blank to keep existing)', video.magnet); const newThumbnail = prompt('New Thumbnail? (Leave blank to keep existing)', video.thumbnail); const newDescription = prompt('New Description? (Leave blank to keep existing)', video.description); // Ask user if they want the note private or public const wantPrivate = confirm('Make this video private? OK=Yes, Cancel=No'); // Fallback to old if user typed nothing const title = (newTitle === null || newTitle.trim() === '') ? video.title : newTitle.trim(); const magnet = (newMagnet === null || newMagnet.trim() === '') ? video.magnet : newMagnet.trim(); const thumbnail = (newThumbnail === null || newThumbnail.trim() === '') ? video.thumbnail : newThumbnail.trim(); const description = (newDescription === null || newDescription.trim() === '') ? video.description : newDescription.trim(); // Build final updated data const updatedData = { version: video.version || 2, // keep old version or set 2 isPrivate: wantPrivate, title, magnet, thumbnail, description, mode: isDevMode ? 'dev' : 'live' }; // Edit const originalEvent = { id: video.id, pubkey: video.pubkey, tags: video.tags }; await nostrClient.editVideo(originalEvent, updatedData, this.pubkey); this.showSuccess('Video updated successfully!'); await this.loadVideos(); } catch (err) { this.log('Failed to edit video:', err.message); this.showError('Failed to edit video. Please try again later.'); } } /** * ADDED FOR VERSIONING/PRIVATE/DELETE: * Allows the user to delete (soft-delete) a video by marking it as deleted. */ async handleDeleteVideo(index) { try { const videos = await nostrClient.fetchVideos(); const video = videos[index]; if (!this.pubkey) { this.showError('Please login to delete videos.'); return; } if (video.pubkey !== this.pubkey) { this.showError('You do not own this video.'); return; } if (!confirm(`Are you sure you want to delete "${video.title}"? This action cannot be undone.`)) { return; } const originalEvent = { id: video.id, pubkey: video.pubkey, tags: video.tags }; await nostrClient.deleteVideo(originalEvent, this.pubkey); this.showSuccess('Video deleted (hidden) successfully!'); await this.loadVideos(); } catch (err) { this.log('Failed to delete video:', err.message); this.showError('Failed to delete video. Please try again later.'); } } } export const app = new bitvidApp(); app.init(); window.app = app;