This commit is contained in:
Keep Creating Online
2025-01-12 09:15:25 -05:00
parent 0d0b1d7e26
commit dcae8bb82f
57 changed files with 15403 additions and 0 deletions

View File

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

1051
refactoring/js/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

View File

View File

View File

View File

@@ -0,0 +1,187 @@
// js/components/VideoPlayer.js
export class VideoPlayer {
constructor() {
// Initialize these as null - they'll be set after modal loads
this.playerModal = null;
this.modalVideo = null;
this.modalStatus = null;
this.modalProgress = null;
this.modalPeers = null;
this.modalSpeed = null;
this.modalDownloaded = null;
this.closePlayerBtn = null;
this.videoTitle = null;
this.videoDescription = null;
this.videoTimestamp = null;
this.creatorAvatar = null;
this.creatorName = null;
this.creatorNpub = null;
this.currentMagnetUri = null;
}
async initModal() {
try {
console.log("Starting modal initialization...");
const response = await fetch("components/video-modal.html");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const html = await response.text();
console.log("Modal HTML loaded successfully");
const modalContainer = document.getElementById("modalContainer");
if (!modalContainer) {
throw new Error("Modal container element not found!");
}
modalContainer.innerHTML = html;
console.log("Modal HTML inserted into DOM");
this.updateModalElements();
await this.setupEventListeners();
console.log("Modal initialization completed successfully");
return true;
} catch (error) {
console.error("Modal initialization failed:", error);
throw error;
}
}
updateModalElements() {
// Update 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("closeModal");
// Update Video Info Elements
this.videoTitle = document.getElementById("videoTitle");
this.videoDescription = document.getElementById("videoDescription");
this.videoTimestamp = document.getElementById("videoTimestamp");
// Update Creator Info Elements
this.creatorAvatar = document.getElementById("creatorAvatar");
this.creatorName = document.getElementById("creatorName");
this.creatorNpub = document.getElementById("creatorNpub");
this.setupScrollBehavior();
}
setupScrollBehavior() {
// Add scroll behavior for nav
let lastScrollY = 0;
const modalNav = document.getElementById("modalNav");
if (this.playerModal && modalNav) {
this.playerModal.addEventListener("scroll", (e) => {
const currentScrollY = e.target.scrollTop;
const shouldShowNav =
currentScrollY <= lastScrollY || currentScrollY < 50;
modalNav.style.transform = shouldShowNav
? "translateY(0)"
: "translateY(-100%)";
lastScrollY = currentScrollY;
});
}
}
async setupEventListeners() {
// Set up modal close handler
if (this.closePlayerBtn) {
this.closePlayerBtn.addEventListener("click", () => this.hide());
}
// Close Modal by clicking outside content
if (this.playerModal) {
this.playerModal.addEventListener("click", async (e) => {
if (e.target === this.playerModal) {
await this.hide();
}
});
}
// Video error handling
if (this.modalVideo) {
this.setupVideoEventListeners();
}
}
setupVideoEventListeners() {
this.modalVideo.addEventListener("error", (e) => {
const error = e.target.error;
console.log("Modal video error:", error);
if (error) {
console.log("Error code:", error.code);
console.log("Error message:", error.message);
// You'll need to implement showError or pass it as a callback
// this.showError(`Video playback error: ${error.message || "Unknown error"}`);
}
});
this.modalVideo.addEventListener("loadstart", () => {
console.log("Video loadstart event fired");
});
this.modalVideo.addEventListener("loadedmetadata", () => {
console.log("Video loadedmetadata event fired");
});
this.modalVideo.addEventListener("canplay", () => {
console.log("Video canplay event fired");
});
}
async hide() {
await this.cleanup();
if (this.playerModal) {
this.playerModal.style.display = "none";
this.playerModal.classList.add("hidden");
}
}
async cleanup() {
if (this.modalVideo) {
this.modalVideo.pause();
this.modalVideo.src = "";
this.modalVideo.load();
}
}
show() {
if (this.playerModal) {
this.playerModal.style.display = "flex";
this.playerModal.classList.remove("hidden");
}
}
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);
}
}
}
export const videoPlayer = new VideoPlayer();

4
refactoring/js/config.js Normal file
View File

@@ -0,0 +1,4 @@
// 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

View File

@@ -0,0 +1,29 @@
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();

File diff suppressed because it is too large Load Diff

13
refactoring/js/lists.js Normal file
View File

@@ -0,0 +1,13 @@
// 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

624
refactoring/js/nostr.js Normal file
View File

@@ -0,0 +1,624 @@
// 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

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

1062
refactoring/js/old/app.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,4 @@
// 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

@@ -0,0 +1,29 @@
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

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

624
refactoring/js/old/nostr.js Normal file
View File

@@ -0,0 +1,624 @@
// 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

@@ -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()

View File

View File

View File

View File

View File

View File

View File

View File

View File

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