This commit is contained in:
Keep Creating Online
2025-01-12 16:02:52 -05:00
parent dcae8bb82f
commit 6bb7c9047f
26 changed files with 399 additions and 2530 deletions

View File

@@ -5,6 +5,8 @@ import { torrentClient } from "./webtorrent.js";
import { isDevMode } from "./config.js";
import { disclaimerModal } from "./disclaimer.js";
import { videoPlayer } from "./components/VideoPlayer.js";
import { videoList } from "./components/VideoList.js";
import { formatTimeAgo } from "./utils/timeUtils.js";
class bitvidApp {
constructor() {
@@ -18,9 +20,6 @@ class bitvidApp {
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");
@@ -64,10 +63,26 @@ class bitvidApp {
this.playerSection.style.display = "none";
}
// Initialize modal first
// Initialize Nostr client first
await nostrClient.init();
// Handle saved pubkey
const savedPubKey = localStorage.getItem("userPubKey");
if (savedPubKey) {
this.login(savedPubKey, false);
}
// Initialize modal
await videoPlayer.initModal();
// Rest of your initialization code...
// Initialize video list
await videoList.loadVideos();
// Initialize and show disclaimer modal
disclaimerModal.show();
// Set up event listeners after all initializations
this.setupEventListeners();
} catch (error) {
console.error("Init failed:", error);
this.showError("Failed to connect to Nostr relay");
@@ -171,30 +186,6 @@ class bitvidApp {
}
}
/**
* 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.
*/
@@ -290,6 +281,9 @@ class bitvidApp {
this.videoFormContainer.classList.remove("hidden");
this.log(`User logged in as: ${pubkey}`);
// ADD: Update videoList pubkey
videoList.setPubkey(pubkey);
if (saveToStorage) {
localStorage.setItem("userPubKey", pubkey);
}
@@ -393,7 +387,8 @@ class bitvidApp {
this.isPrivateCheckbox.checked = false;
}
await this.loadVideos();
// CHANGE: Use videoList component to refresh
await videoList.loadVideos(); // <-- Change this line
this.showSuccess("Video shared successfully!");
} catch (error) {
this.log("Failed to publish video:", error.message);
@@ -401,319 +396,6 @@ class bitvidApp {
}
}
/**
* Loads and displays videos from Nostr.
*/
async loadVideos() {
console.log("Starting loadVideos...");
try {
const videos = await nostrClient.fetchVideos();
console.log("Raw videos from nostrClient:", 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 = `
<p class="text-center text-gray-500">
No public videos available yet. Be the first to upload one!
</p>`;
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
await 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 = `
<p class="text-center text-gray-500">
No videos available at the moment. Please try again later.
</p>`;
}
}
/**
* 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 = `<p class="text-center text-gray-500">No videos found.</p>`;
return;
}
const videoArray = Array.isArray(videos) ? videos : [videos];
if (videoArray.length === 0) {
console.error("VIDEO ARRAY IS EMPTY");
this.videoList.innerHTML = `<p class="text-center text-gray-500">No videos available.</p>`;
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
? `
<div class="relative inline-block ml-3 overflow-visible">
<button
type="button"
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
onclick="document.getElementById('settingsDropdown-${index}').classList.toggle('hidden')"
>
<img
src="assets/svg/video-settings-gear.svg"
alt="Settings"
class="w-5 h-5"
/>
</button>
<!-- The dropdown appears above the gear (bottom-full) -->
<div
id="settingsDropdown-${index}"
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50"
>
<div class="py-1">
<button
class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
onclick="app.handleEditVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
>
Edit
</button>
<button
class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
onclick="app.handleDeleteVideo(${index}); document.getElementById('settingsDropdown-${index}').classList.add('hidden');"
>
Delete
</button>
</div>
</div>
</div>
`
: "";
return `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
<!-- VIDEO THUMBNAIL -->
<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>
<!-- CARD INFO -->
<div class="p-4">
<!-- TITLE -->
<h3
class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
onclick="app.playVideo('${encodeURIComponent(
video.magnet
)}')"
>
${this.escapeHTML(video.title)}
</h3>
<!-- CREATOR info + gear icon -->
<div class="flex items-center justify-between">
<!-- Left: Avatar & user/time -->
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
<img
src="${this.escapeHTML(
profile.picture
)}"
alt="${profile.name}"
class="w-full h-full object-cover"
>
</div>
<div class="min-w-0">
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
${this.escapeHTML(profile.name)}
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div>
</div>
<!-- Right: gearMenu if user owns the video -->
${gearMenu}
</div>
</div>
</div>
`;
} catch (error) {
console.error(`Error processing video ${index}:`, error);
return "";
}
})
.filter((html) => html.length > 0);
console.log("Rendered videos:", renderedVideos.length);
if (renderedVideos.length === 0) {
this.videoList.innerHTML = `<p class="text-center text-gray-500">No valid videos to display.</p>`;
return;
}
this.videoList.innerHTML = renderedVideos.join("");
console.log("Videos rendered successfully");
} catch (error) {
console.error("Rendering error:", error);
this.videoList.innerHTML = `<p class="text-center text-gray-500">Error loading videos.</p>`;
}
}
/**
* 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.
*/
@@ -761,18 +443,6 @@ class bitvidApp {
}
}
/**
* 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.
*/
@@ -862,7 +532,7 @@ class bitvidApp {
this.videoTitle.textContent = video.title || "Untitled";
this.videoDescription.textContent =
video.description || "No description available.";
this.videoTimestamp.textContent = this.formatTimeAgo(video.created_at);
this.videoTimestamp.textContent = formatTimeAgo(video.created_at);
this.creatorName.textContent = creatorProfile.name;
this.creatorNpub.textContent = `${creatorNpub.slice(
@@ -927,6 +597,7 @@ class bitvidApp {
*/
async handleEditVideo(index) {
try {
// CHANGE: Get videos through videoList component
const videos = await nostrClient.fetchVideos();
const video = videos[index];
@@ -997,7 +668,7 @@ class bitvidApp {
};
await nostrClient.editVideo(originalEvent, updatedData, this.pubkey);
this.showSuccess("Video updated successfully!");
await this.loadVideos();
await videoList.loadVideos();
} catch (err) {
this.log("Failed to edit video:", err.message);
this.showError("Failed to edit video. Please try again later.");
@@ -1010,6 +681,7 @@ class bitvidApp {
*/
async handleDeleteVideo(index) {
try {
// CHANGE: Get videos through videoList component
const videos = await nostrClient.fetchVideos();
const video = videos[index];
@@ -1038,7 +710,8 @@ class bitvidApp {
await nostrClient.deleteVideo(originalEvent, this.pubkey);
this.showSuccess("Video deleted (hidden) successfully!");
await this.loadVideos();
// CHANGE: Use videoList component to refresh
await videoList.loadVideos();
} catch (err) {
this.log("Failed to delete video:", err.message);
this.showError("Failed to delete video. Please try again later.");

View File

@@ -0,0 +1,285 @@
// js/components/VideoList.js
import { nostrClient } from "../nostr.js";
import { formatTimeAgo } from "../utils/timeUtils.js";
import { escapeHTML } from "../utils/htmlUtils.js";
export class VideoList {
constructor() {
this.videoList = document.getElementById("videoList");
this.pubkey = null; // We'll need this for private video filtering
}
setPubkey(pubkey) {
this.pubkey = pubkey;
}
async loadVideos() {
console.log("Starting loadVideos...");
try {
const videos = await nostrClient.fetchVideos();
console.log("Raw videos from nostrClient:", videos);
if (!videos) {
console.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 private videos
const displayedVideos = videosArray.filter((video) => {
if (!video.isPrivate) {
return true;
}
return this.pubkey && video.pubkey === this.pubkey;
});
if (displayedVideos.length === 0) {
console.log("No valid videos found after filtering.");
this.renderEmptyState(
"No public videos available yet. Be the first to upload one!"
);
return;
}
console.log("Processing filtered videos:", displayedVideos);
await this.renderVideoList(displayedVideos);
console.log(`Rendered ${displayedVideos.length} videos successfully`);
} catch (error) {
console.log("Failed to fetch videos:", error);
this.renderEmptyState(
"No videos available at the moment. Please try again later."
);
}
}
renderEmptyState(message) {
if (this.videoList) {
this.videoList.innerHTML = `
<p class="text-center text-gray-500">
${escapeHTML(message)}
</p>`;
}
}
async renderVideoList(videos) {
try {
console.log("RENDER VIDEO LIST - Start", {
videosReceived: videos,
videosCount: videos ? videos.length : "N/A",
videosType: typeof videos,
});
if (!videos || videos.length === 0) {
this.renderEmptyState("No videos found.");
return;
}
// Sort by creation date
const videoArray = [...videos].sort(
(a, b) => b.created_at - a.created_at
);
// Fetch user profiles
const userProfiles = await this.fetchUserProfiles(videoArray);
// Build HTML for each video
const renderedVideos = videoArray
.map((video, index) => this.renderVideoCard(video, index, userProfiles))
.filter(Boolean);
if (renderedVideos.length === 0) {
this.renderEmptyState("No valid videos to display.");
return;
}
this.videoList.innerHTML = renderedVideos.join("");
console.log("Videos rendered successfully");
} catch (error) {
console.error("Rendering error:", error);
this.renderEmptyState("Error loading videos.");
}
}
async fetchUserProfiles(videos) {
const userProfiles = new Map();
const uniquePubkeys = [...new Set(videos.map((v) => v.pubkey))];
for (const pubkey of uniquePubkeys) {
try {
const profile = await nostrClient.fetchUserProfile(pubkey);
userProfiles.set(pubkey, profile);
} catch (error) {
console.error(`Profile fetch error for ${pubkey}:`, error);
userProfiles.set(pubkey, {
name: "Unknown",
picture: `https://robohash.org/${pubkey}`,
});
}
}
return userProfiles;
}
renderVideoCard(video, index, userProfiles) {
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 canEdit = video.pubkey === this.pubkey;
const highlightClass =
video.isPrivate && canEdit
? "border-2 border-yellow-500"
: "border-none";
return `
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass}">
${this.renderThumbnail(video)}
${this.renderCardInfo(video, profile)}
</div>
`;
} catch (error) {
console.error(`Error processing video ${index}:`, error);
return "";
}
}
renderThumbnail(video) {
return `
<div
class="aspect-w-16 aspect-h-9 bg-gray-800 cursor-pointer relative group"
onclick="window.app.playVideo('${encodeURIComponent(video.magnet)}')"
>
${
video.thumbnail
? this.renderThumbnailImage(video)
: this.renderPlaceholderThumbnail()
}
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-20 transition-opacity duration-300"></div>
</div>
`;
}
renderThumbnailImage(video) {
return `
<img
src="${escapeHTML(video.thumbnail)}"
alt="${escapeHTML(video.title)}"
class="w-full h-full object-cover"
>
`;
}
renderPlaceholderThumbnail() {
return `
<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>
`;
}
renderCardInfo(video, profile) {
const timeAgo = formatTimeAgo(video.created_at);
const canEdit = video.pubkey === this.pubkey;
return `
<div class="p-4">
<h3 class="text-lg font-bold text-white line-clamp-2 hover:text-blue-400 cursor-pointer mb-3"
onclick="window.app.playVideo('${encodeURIComponent(
video.magnet
)}')">
${escapeHTML(video.title)}
</h3>
<div class="flex items-center justify-between">
<div class="flex items-center space-x-3">
<div class="w-8 h-8 rounded-full bg-gray-700 overflow-hidden">
<img src="${escapeHTML(profile.picture)}"
alt="${profile.name}"
class="w-full h-full object-cover">
</div>
<div class="min-w-0">
<p class="text-sm text-gray-400 hover:text-gray-300 cursor-pointer">
${escapeHTML(profile.name)}
</p>
<div class="flex items-center text-xs text-gray-500 mt-1">
<span>${timeAgo}</span>
</div>
</div>
</div>
${this.renderGearMenu(video, canEdit)}
</div>
</div>
`;
}
renderGearMenu(video, canEdit) {
if (!canEdit) return "";
return `
<div class="relative inline-block ml-3 overflow-visible">
<button type="button"
class="inline-flex items-center p-2 rounded-full text-gray-400 hover:text-gray-200 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-blue-500"
onclick="document.getElementById('settingsDropdown-${video.id}').classList.toggle('hidden')">
<img src="assets/svg/video-settings-gear.svg"
alt="Settings"
class="w-5 h-5"/>
</button>
<div id="settingsDropdown-${video.id}"
class="hidden absolute right-0 bottom-full mb-2 w-32 rounded-md shadow-lg bg-gray-800 ring-1 ring-black ring-opacity-5 z-50">
<div class="py-1">
<button class="block w-full text-left px-4 py-2 text-sm text-gray-100 hover:bg-gray-700"
onclick="app.handleEditVideo('${video.id}'); document.getElementById('settingsDropdown-${video.id}').classList.add('hidden');">
Edit
</button>
<button class="block w-full text-left px-4 py-2 text-sm text-red-400 hover:bg-red-700 hover:text-white"
onclick="app.handleDeleteVideo('${video.id}'); document.getElementById('settingsDropdown-${video.id}').classList.add('hidden');">
Delete
</button>
</div>
</div>
</div>
`;
}
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;
}
}
export const videoList = new VideoList();

View File

@@ -577,6 +577,59 @@ class NostrClient {
}
}
/**
* Fetches a user profile given a pubkey.
* Returns an object with 'name' and 'picture' properties.
*/
async fetchUserProfile(pubkey) {
if (!pubkey) {
throw new Error("Invalid pubkey provided.");
}
if (isDevMode) {
console.log(`Fetching profile for pubkey: ${pubkey}`);
}
const filter = {
kinds: [0], // Profile events
authors: [pubkey],
limit: 1,
};
try {
const events = await this.pool.list(this.relays, [filter]);
if (events.length === 0) {
if (isDevMode) {
console.log(`No profile found for pubkey: ${pubkey}`);
}
return {
name: "Unknown",
picture: `https://robohash.org/${pubkey}`,
};
}
const profileContent = JSON.parse(events[0].content || "{}");
const profile = {
name:
profileContent.name ||
profileContent.display_name ||
`User ${pubkey.slice(0, 8)}...`,
picture: profileContent.picture || `https://robohash.org/${pubkey}`,
};
if (isDevMode) {
console.log(`Fetched profile for ${pubkey}:`, profile);
}
return profile;
} catch (error) {
logErrorOnce(`Error fetching profile for ${pubkey}:`, error.message);
throw error;
}
}
/**
* Validates video content structure.
*/

View File

@@ -1,154 +0,0 @@
// js/accessControl.js
import { isDevMode, isWhitelistEnabled } from "./config.js";
import { initialWhitelist, initialBlacklist } from "./lists.js";
class AccessControl {
constructor() {
// Debug logging for initialization
console.log("DEBUG: AccessControl constructor called");
console.log("DEBUG: initialWhitelist from import:", initialWhitelist);
console.log("DEBUG: typeof initialWhitelist:", typeof initialWhitelist);
console.log("DEBUG: initialWhitelist length:", initialWhitelist.length);
// Initialize empty sets
this.whitelist = new Set(initialWhitelist);
this.blacklist = new Set(initialBlacklist.filter((x) => x)); // Filter out empty strings
// Debug the sets
console.log("DEBUG: Whitelist after Set creation:", [...this.whitelist]);
console.log("DEBUG: Blacklist after Set creation:", [...this.blacklist]);
// Save to localStorage
this.saveWhitelist();
this.saveBlacklist();
}
// Rest of the class remains the same...
loadWhitelist() {
try {
const stored = localStorage.getItem("bitvid_whitelist");
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("Error loading whitelist:", error);
return [];
}
}
loadBlacklist() {
try {
const stored = localStorage.getItem("bitvid_blacklist");
return stored ? JSON.parse(stored) : [];
} catch (error) {
console.error("Error loading blacklist:", error);
return [];
}
}
saveWhitelist() {
try {
localStorage.setItem(
"bitvid_whitelist",
JSON.stringify([...this.whitelist])
);
} catch (error) {
console.error("Error saving whitelist:", error);
}
}
saveBlacklist() {
try {
localStorage.setItem(
"bitvid_blacklist",
JSON.stringify([...this.blacklist])
);
} catch (error) {
console.error("Error saving blacklist:", error);
}
}
addToWhitelist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.whitelist.add(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Added ${npub} to whitelist`);
}
removeFromWhitelist(npub) {
this.whitelist.delete(npub);
this.saveWhitelist();
if (isDevMode) console.log(`Removed ${npub} from whitelist`);
}
addToBlacklist(npub) {
if (!this.isValidNpub(npub)) {
throw new Error("Invalid npub format");
}
this.blacklist.add(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Added ${npub} to blacklist`);
}
removeFromBlacklist(npub) {
this.blacklist.delete(npub);
this.saveBlacklist();
if (isDevMode) console.log(`Removed ${npub} from blacklist`);
}
isWhitelisted(npub) {
const result = this.whitelist.has(npub);
if (isDevMode)
console.log(
`Checking if ${npub} is whitelisted:`,
result,
"Current whitelist:",
[...this.whitelist]
);
return result;
}
isBlacklisted(npub) {
return this.blacklist.has(npub);
}
canAccess(npub) {
if (this.isBlacklisted(npub)) {
return false;
}
const canAccess = !isWhitelistEnabled || this.isWhitelisted(npub);
if (isDevMode) console.log(`Checking access for ${npub}:`, canAccess);
return canAccess;
}
filterVideos(videos) {
return videos.filter((video) => {
try {
const npub = window.NostrTools.nip19.npubEncode(video.pubkey);
return !this.isBlacklisted(npub);
} catch (error) {
console.error("Error filtering video:", error);
return false;
}
});
}
isValidNpub(npub) {
try {
return npub.startsWith("npub1") && npub.length === 63;
} catch (error) {
return false;
}
}
getWhitelist() {
return [...this.whitelist];
}
getBlacklist() {
return [...this.blacklist];
}
}
export const accessControl = new AccessControl();

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +0,0 @@
// js/config.js
export const isDevMode = true; // Set to false for production
export const isWhitelistEnabled = true; // Set to false to allow all non-blacklisted users

View File

@@ -1,29 +0,0 @@
class DisclaimerModal {
constructor() {
this.modal = document.getElementById("disclaimerModal");
this.acceptButton = document.getElementById("acceptDisclaimer");
this.hasSeenDisclaimer = localStorage.getItem("hasSeenDisclaimer");
this.setupEventListeners();
}
setupEventListeners() {
const closeModal = () => {
this.modal.style.display = "none";
document.body.style.overflow = "unset";
localStorage.setItem("hasSeenDisclaimer", "true");
};
// Only keep the accept button event listener
this.acceptButton.addEventListener("click", closeModal);
}
show() {
if (!this.hasSeenDisclaimer) {
this.modal.style.display = "flex";
document.body.style.overflow = "hidden";
}
}
}
export const disclaimerModal = new DisclaimerModal();

View File

@@ -1,13 +0,0 @@
// js/lists.js
const npubs = [
"npub13yarr7j6vjqjjkahd63dmr27curypehx45ucue286ac7sft27y0srnpmpe",
"npub15jnttpymeytm80hatjqcvhhqhzrhx6gxp8pq0wn93rhnu8s9h9dsha32lx",
"npub1j37gc05qpqzyrmdc5vetsc9h5qtstas7tr25j0n9sdpqxghz6m4q2ej6n8",
"npub1epvnvv3kskvpnmpqgnm2atevsmdferhp7dg2s0yc7uc0hdmqmgssx09tu2",
];
console.log("DEBUG: lists.js loaded, npubs:", npubs);
export const initialWhitelist = npubs;
export const initialBlacklist = [""];

View File

@@ -1,624 +0,0 @@
// js/nostr.js
import { isDevMode } from "./config.js";
import { accessControl } from "./accessControl.js";
const RELAY_URLS = [
"wss://relay.damus.io",
"wss://nos.lol",
"wss://relay.snort.social",
"wss://nostr.wine",
];
// Rate limiting for error logs
let errorLogCount = 0;
const MAX_ERROR_LOGS = 100; // Adjust as needed
function logErrorOnce(message, eventContent = null) {
if (errorLogCount < MAX_ERROR_LOGS) {
console.error(message);
if (eventContent) {
console.log(`Event Content: ${eventContent}`);
}
errorLogCount++;
}
if (errorLogCount === MAX_ERROR_LOGS) {
console.error(
"Maximum error log limit reached. Further errors will be suppressed."
);
}
}
/**
* A very naive "encryption" function that just reverses the string.
* In a real app, use a proper crypto library (AES-GCM, ECDH, etc.).
*/
function fakeEncrypt(magnet) {
return magnet.split("").reverse().join("");
}
function fakeDecrypt(encrypted) {
return encrypted.split("").reverse().join("");
}
class NostrClient {
constructor() {
this.pool = null;
this.pubkey = null;
this.relays = RELAY_URLS;
}
/**
* Initializes the Nostr client by connecting to relays.
*/
async init() {
if (isDevMode) console.log("Connecting to relays...");
try {
this.pool = new window.NostrTools.SimplePool();
const results = await this.connectToRelays();
const successfulRelays = results
.filter((r) => r.success)
.map((r) => r.url);
if (successfulRelays.length === 0) throw new Error("No relays connected");
if (isDevMode)
console.log(`Connected to ${successfulRelays.length} relay(s)`);
} catch (err) {
console.error("Nostr init failed:", err);
throw err;
}
}
// Helper method to handle relay connections
async connectToRelays() {
return Promise.all(
this.relays.map(
(url) =>
new Promise((resolve) => {
const sub = this.pool.sub([url], [{ kinds: [0], limit: 1 }]);
const timeout = setTimeout(() => {
sub.unsub();
resolve({ url, success: false });
}, 5000);
const succeed = () => {
clearTimeout(timeout);
sub.unsub();
resolve({ url, success: true });
};
sub.on("event", succeed);
sub.on("eose", succeed);
})
)
);
}
/**
* Logs in the user using a Nostr extension or by entering an NSEC key.
*/
async login() {
try {
if (!window.nostr) {
console.log("No Nostr extension found");
throw new Error(
"Please install a Nostr extension (like Alby or nos2x)."
);
}
const pubkey = await window.nostr.getPublicKey();
const npub = window.NostrTools.nip19.npubEncode(pubkey);
// Debug logs
if (isDevMode) {
console.log("Got pubkey:", pubkey);
console.log("Converted to npub:", npub);
console.log("Whitelist:", accessControl.getWhitelist());
console.log("Blacklist:", accessControl.getBlacklist());
console.log("Is whitelisted?", accessControl.isWhitelisted(npub));
console.log("Is blacklisted?", accessControl.isBlacklisted(npub));
}
// Check access control
if (!accessControl.canAccess(npub)) {
if (accessControl.isBlacklisted(npub)) {
throw new Error(
"Your account has been blocked from accessing this platform."
);
} else {
throw new Error(
"Access is currently restricted to whitelisted users only."
);
}
}
this.pubkey = pubkey;
if (isDevMode)
console.log(
"Successfully logged in with extension. Public key:",
this.pubkey
);
return this.pubkey;
} catch (e) {
console.error("Login error:", e);
throw e;
}
}
/**
* 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 (creates a brand-new note).
*/
async publishVideo(videoData, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (isDevMode) {
console.log("Publishing video with data:", videoData);
}
// If user sets "isPrivate = true", encrypt the magnet
let finalMagnet = videoData.magnet;
if (videoData.isPrivate === true) {
finalMagnet = fakeEncrypt(finalMagnet);
}
// Default version is 1 if not specified
const version = videoData.version ?? 1;
const uniqueD = `${Date.now()}-${Math.random()
.toString(36)
.substring(2, 10)}`;
// Always mark "deleted" false for new posts
const contentObject = {
version,
deleted: false,
isPrivate: videoData.isPrivate || false,
title: videoData.title,
magnet: finalMagnet,
thumbnail: videoData.thumbnail,
description: videoData.description,
mode: videoData.mode,
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", uniqueD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Event content after stringify:", event.content);
console.log("Using d tag:", uniqueD);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(`Event published to ${url}`);
}
} catch (err) {
if (isDevMode) {
console.error(`Failed to publish to ${url}:`, err.message);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign event:", error.message);
}
throw new Error("Failed to sign event.");
}
}
/**
* Edits an existing video event by reusing the same "d" tag.
* Allows toggling isPrivate on/off and re-encrypting or decrypting the magnet.
*/
// Minimal fix: ensures we only ever encrypt once per edit operation
async editVideo(originalEvent, updatedVideoData, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
}
if (isDevMode) {
console.log("Editing video event:", originalEvent);
console.log("New video data:", updatedVideoData);
}
// Grab the d tag from the original event
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
if (!dTag) {
throw new Error(
'This event has no "d" tag, cannot edit as addressable kind=30078.'
);
}
const existingD = dTag[1];
// Parse old content
const oldContent = JSON.parse(originalEvent.content || "{}");
if (isDevMode) {
console.log("Old content:", oldContent);
}
// Keep old version & deleted status
const oldVersion = oldContent.version ?? 1;
const oldDeleted = oldContent.deleted === true;
const newVersion = updatedVideoData.version ?? oldVersion;
const oldWasPrivate = oldContent.isPrivate === true;
// 1) If old was private, decrypt the old magnet once => oldPlainMagnet
let oldPlainMagnet = oldContent.magnet || "";
if (oldWasPrivate && oldPlainMagnet) {
oldPlainMagnet = fakeDecrypt(oldPlainMagnet);
}
// 2) If updatedVideoData.isPrivate is explicitly set, use that; else keep the old isPrivate
const newIsPrivate =
typeof updatedVideoData.isPrivate === "boolean"
? updatedVideoData.isPrivate
: oldContent.isPrivate ?? false;
// 3) The user might type a new magnet or keep oldPlainMagnet
const userTypedMagnet = (updatedVideoData.magnet || "").trim();
const finalPlainMagnet = userTypedMagnet || oldPlainMagnet;
// 4) If new is private => encrypt finalPlainMagnet once; otherwise store plaintext
let finalMagnet = finalPlainMagnet;
if (newIsPrivate) {
finalMagnet = fakeEncrypt(finalPlainMagnet);
}
// Build updated content
const contentObject = {
version: newVersion,
deleted: oldDeleted,
isPrivate: newIsPrivate,
title: updatedVideoData.title,
magnet: finalMagnet,
thumbnail: updatedVideoData.thumbnail,
description: updatedVideoData.description,
mode: updatedVideoData.mode,
};
if (isDevMode) {
console.log("Building updated content object:", contentObject);
}
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag:", existingD);
console.log("Updated event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed edited event:", signedEvent);
}
// Publish to all relays
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(
`Edited event published to ${url} (d="${existingD}")`
);
}
} catch (err) {
if (isDevMode) {
console.error(
`Failed to publish edited event to ${url}:`,
err.message
);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign edited event:", error.message);
}
throw new Error("Failed to sign edited event.");
}
}
/**
* Soft-delete or hide an existing video by marking content as "deleted: true"
* and republishing with same (kind=30078, pubkey, d) address.
*/
async deleteVideo(originalEvent, pubkey) {
if (!pubkey) {
throw new Error("User is not logged in.");
}
if (originalEvent.pubkey !== pubkey) {
throw new Error("You do not own this event (different pubkey).");
}
if (isDevMode) {
console.log("Deleting video event:", originalEvent);
}
const dTag = originalEvent.tags.find((tag) => tag[0] === "d");
if (!dTag) {
throw new Error(
'This event has no "d" tag, cannot delete as addressable kind=30078.'
);
}
const existingD = dTag[1];
const oldContent = JSON.parse(originalEvent.content || "{}");
const oldVersion = oldContent.version ?? 1;
const contentObject = {
version: oldVersion,
deleted: true,
title: oldContent.title || "",
magnet: "",
thumbnail: "",
description: "This video has been deleted.",
mode: oldContent.mode || "live",
isPrivate: oldContent.isPrivate || false,
};
const event = {
kind: 30078,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: [
["t", "video"],
["d", existingD],
],
content: JSON.stringify(contentObject),
};
if (isDevMode) {
console.log("Reusing d tag for delete:", existingD);
console.log("Deleted event content:", event.content);
}
try {
const signedEvent = await window.nostr.signEvent(event);
if (isDevMode) {
console.log("Signed deleted event:", signedEvent);
}
await Promise.all(
this.relays.map(async (url) => {
try {
await this.pool.publish([url], signedEvent);
if (isDevMode) {
console.log(
`Deleted event published to ${url} (d="${existingD}")`
);
}
} catch (err) {
if (isDevMode) {
console.error(
`Failed to publish deleted event to ${url}:`,
err.message
);
}
}
})
);
return signedEvent;
} catch (error) {
if (isDevMode) {
console.error("Failed to sign deleted event:", error.message);
}
throw new Error("Failed to sign deleted event.");
}
}
/**
* Fetches videos from all configured relays.
*/
async fetchVideos() {
const filter = {
kinds: [30078],
"#t": ["video"],
limit: 1000,
since: 0,
};
const videoEvents = new Map();
if (isDevMode) {
console.log("[fetchVideos] Starting fetch from all relays...");
console.log("[fetchVideos] Filter:", filter);
}
try {
await Promise.all(
this.relays.map(async (url) => {
if (isDevMode) console.log(`[fetchVideos] Querying relay: ${url}`);
try {
const events = await this.pool.list([url], [filter]);
if (isDevMode) {
console.log(`Events from ${url}:`, events.length);
if (events.length > 0) {
events.forEach((evt, idx) => {
console.log(
`[fetchVideos] [${url}] Event[${idx}] ID: ${evt.id} | pubkey: ${evt.pubkey} | created_at: ${evt.created_at}`
);
});
}
}
events.forEach((event) => {
try {
const content = JSON.parse(event.content);
// If deleted == true, it overrides older notes
if (content.deleted === true) {
videoEvents.delete(event.id);
return;
}
// If we haven't seen this event.id before, store it
if (!videoEvents.has(event.id)) {
videoEvents.set(event.id, {
id: event.id,
version: content.version ?? 1,
isPrivate: content.isPrivate ?? false,
title: content.title || "",
magnet: content.magnet || "",
thumbnail: content.thumbnail || "",
description: content.description || "",
mode: content.mode || "live",
pubkey: event.pubkey,
created_at: event.created_at,
tags: event.tags,
});
}
} catch (parseError) {
if (isDevMode) {
console.error(
"[fetchVideos] Event parsing error:",
parseError
);
}
}
});
} catch (relayError) {
if (isDevMode) {
console.error(
`[fetchVideos] Error fetching from ${url}:`,
relayError
);
}
}
})
);
const videos = Array.from(videoEvents.values()).sort(
(a, b) => b.created_at - a.created_at
);
// Apply access control filtering
const filteredVideos = accessControl.filterVideos(videos);
if (isDevMode) {
console.log("[fetchVideos] All relays have responded.");
console.log(
`[fetchVideos] Total unique video events: ${videoEvents.size}`
);
console.log(
`[fetchVideos] Videos after filtering: ${filteredVideos.length}`
);
}
return filteredVideos;
} catch (error) {
if (isDevMode) {
console.error("FETCH VIDEOS ERROR:", error);
}
return [];
}
}
/**
* 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");
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();

View File

@@ -1,286 +0,0 @@
// 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()

View File

@@ -0,0 +1,9 @@
// js/utils/htmlUtils.js
export function escapeHTML(unsafe) {
return unsafe
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@@ -0,0 +1,21 @@
// js/utils/timeUtils.js
export function 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";
}