diff --git a/components/upload-modal.html b/components/upload-modal.html
index c8026d5e..5f716daf 100644
--- a/components/upload-modal.html
+++ b/components/upload-modal.html
@@ -103,6 +103,27 @@
>
+
+
+
Generated Links
+
+
+
+
+
+
+
+
+
+
+
+
+ Uploading Thumbnail...
+ 0%
+
+
+
diff --git a/js/services/r2Service.js b/js/services/r2Service.js
index 60c2840f..03f5b0df 100644
--- a/js/services/r2Service.js
+++ b/js/services/r2Service.js
@@ -42,7 +42,7 @@ import {
} from "../storage/s3-multipart.js";
import { ensureS3SdkLoaded, makeS3Client } from "../storage/s3-client.js";
import { truncateMiddle } from "../utils/formatters.js";
-import { userLogger } from "../utils/logger.js";
+import { userLogger, devLogger } from "../utils/logger.js";
import {
getVideoNoteErrorMessage,
normalizeVideoNotePayload,
@@ -555,7 +555,7 @@ class R2Service {
} catch (createErr) {
// 403 Forbidden is expected if keys are "Object Read & Write" only.
// We proceed assuming the user might have created it manually.
- userLogger.debug(
+ devLogger.debug(
"Auto-creation of bucket failed (likely permission issue), proceeding...",
createErr
);
@@ -749,6 +749,50 @@ class R2Service {
return;
}
+ /**
+ * Prepares for an upload by resolving credentials and ensuring the bucket exists.
+ * @param {string} npub
+ * @param {object} options
+ * @returns {Promise<{settings: object, bucketEntry: object}>}
+ */
+ async prepareUpload(npub, { credentials } = {}) {
+ // Resolve settings from StorageService if explicitCredentials missing
+ let effectiveSettings = credentials;
+ if (!effectiveSettings) {
+ effectiveSettings = await this.resolveConnection(npub);
+ }
+ if (!effectiveSettings) {
+ effectiveSettings = this.cloudflareSettings || {};
+ }
+
+ const accountId = (effectiveSettings.accountId || "").trim();
+ const accessKeyId = (effectiveSettings.accessKeyId || "").trim();
+ const secretAccessKey = (effectiveSettings.secretAccessKey || "").trim();
+
+ if (!accountId || !accessKeyId || !secretAccessKey) {
+ throw new Error("Missing R2 credentials. Unlock your storage or save settings.");
+ }
+
+ let bucketResult = null;
+ try {
+ bucketResult = await this.ensureBucketConfigForNpub(npub, {
+ credentials: effectiveSettings,
+ });
+ } catch (err) {
+ userLogger.error("Failed to prepare R2 bucket:", err);
+ throw new Error(err?.message ? `Bucket setup failed: ${err.message}` : "Bucket setup failed.");
+ }
+
+ const bucketEntry =
+ bucketResult?.entry || this.cloudflareSettings?.buckets?.[npub];
+
+ if (!bucketEntry || !bucketEntry.publicBaseUrl) {
+ throw new Error("Bucket is missing a public URL. Check your settings.");
+ }
+
+ return { settings: effectiveSettings, bucketEntry };
+ }
+
/**
* Orchestrates the entire video upload workflow.
*
diff --git a/js/services/s3UploadService.js b/js/services/s3UploadService.js
index 02f9de3f..5ab61c58 100644
--- a/js/services/s3UploadService.js
+++ b/js/services/s3UploadService.js
@@ -98,6 +98,68 @@ class S3UploadService {
return prepared;
}
+ async prepareUpload(settings = {}, { createBucketIfMissing = true } = {}) {
+ const prepared = await this.verifyConnection({ settings, createBucketIfMissing });
+ return {
+ settings: prepared,
+ bucketEntry: {
+ bucket: prepared.bucket,
+ publicBaseUrl: prepared.publicBaseUrl,
+ },
+ };
+ }
+
+ async uploadFile({
+ file,
+ endpoint,
+ region,
+ accessKeyId,
+ secretAccessKey,
+ forcePathStyle,
+ bucket,
+ key,
+ onProgress,
+ createBucketIfMissing = true,
+ } = {}) {
+ if (
+ !file ||
+ !bucket ||
+ !key ||
+ !endpoint ||
+ !accessKeyId ||
+ !secretAccessKey
+ ) {
+ throw new Error("Missing required parameters for uploadFile");
+ }
+
+ await ensureS3SdkLoaded();
+
+ const s3 = makeS3Client({
+ endpoint,
+ region,
+ accessKeyId,
+ secretAccessKey,
+ forcePathStyle: Boolean(forcePathStyle),
+ });
+
+ await multipartUpload({
+ s3,
+ bucket,
+ key,
+ file,
+ contentType: file.type || "application/octet-stream",
+ createBucketIfMissing,
+ region,
+ onProgress: (fraction) => {
+ if (typeof onProgress === "function") {
+ onProgress(fraction);
+ }
+ },
+ });
+
+ return { bucket, key };
+ }
+
async uploadVideo({
npub = "",
file = null,
diff --git a/js/ui/components/UploadModal.js b/js/ui/components/UploadModal.js
index 3f7d720f..1317e725 100644
--- a/js/ui/components/UploadModal.js
+++ b/js/ui/components/UploadModal.js
@@ -79,11 +79,35 @@ export class UploadModal {
this.isStorageUnlocked = false;
this.storageConfigured = false;
+ // Upload State
+ this.videoUploadState = {
+ status: 'idle', // idle, uploading, complete, error
+ progress: 0,
+ url: '',
+ key: '',
+ file: null,
+ };
+ this.thumbnailUploadState = {
+ status: 'idle',
+ progress: 0,
+ url: '',
+ key: '',
+ file: null,
+ };
+ this.torrentState = {
+ status: 'idle',
+ infoHash: '',
+ magnet: '',
+ url: '', // xs (torrent file url)
+ file: null,
+ };
+
// UI References
this.form = null;
this.modeButtons = {};
this.sourceSections = {};
this.inputs = {};
+ this.results = {}; // New results section
this.toggles = {};
this.submitButton = null;
this.submitStatus = null;
@@ -98,7 +122,7 @@ export class UploadModal {
// Automation
this.summaryLocked = true;
- this.isUploading = false;
+ this.isUploading = false; // Global lock
}
// --- Core Lifecycle ---
@@ -131,7 +155,7 @@ export class UploadModal {
this.cacheElements();
this.bindEvents();
this.setupModalAccessibility();
- this.registerStorageSubscriptions();
+ // this.registerStorageSubscriptions(); // We handle progress locally per file now
// Initial State
if (this.storageService) {
@@ -173,6 +197,8 @@ export class UploadModal {
settings: $("#section-storage-settings"),
advanced: $("#section-advanced"),
progress: $("#upload-progress-container"),
+ thumbnailProgress: $("#thumbnail-progress-container"),
+ results: $("#upload-results-container"),
};
this.storageViews = {
@@ -199,6 +225,14 @@ export class UploadModal {
// Progress
progress: $("#input-progress"),
+ thumbnailProgress: $("#thumbnail-progress"),
+ };
+
+ // Results (Generated Links)
+ this.results = {
+ videoUrl: $("#result-video-url"),
+ magnet: $("#result-magnet"),
+ torrentUrl: $("#result-torrent-url"),
};
// Toggles/Buttons
@@ -221,6 +255,8 @@ export class UploadModal {
storageLock: $("#storage-lock-status"),
uploadMain: $("#upload-status-text"),
uploadPercent: $("#upload-percent-text"),
+ thumbnailMain: $("#thumbnail-status-text"),
+ thumbnailPercent: $("#thumbnail-percent-text"),
summaryProvider: $("#summary-provider"),
summaryBucket: $("#summary-bucket"),
summaryUrlStyle: $("#summary-url-style"),
@@ -250,7 +286,7 @@ export class UploadModal {
this.toggles.storageUnlock.addEventListener("click", () => this.handleUnlock());
}
- // Manage Storage (Summary View)
+ // Manage Storage
if (this.toggles.manageStorage) {
this.toggles.manageStorage.addEventListener("click", () => {
if (this.onRequestStorageSettings) {
@@ -260,10 +296,9 @@ export class UploadModal {
});
}
- // Configure Storage (Empty View)
if (this.toggles.configureStorage) {
this.toggles.configureStorage.addEventListener("click", (e) => {
- e.preventDefault(); // Prevent form submission
+ e.preventDefault();
if (this.onRequestStorageSettings) {
this.close();
this.onRequestStorageSettings();
@@ -277,6 +312,11 @@ export class UploadModal {
this.setupNsfwToContentWarning();
this.setupThumbnailInput();
+ // File Selection (Triggers upload immediately)
+ if (this.inputs.file) {
+ this.inputs.file.addEventListener("change", (e) => this.handleVideoSelection(e));
+ }
+
// Close
this.closeButton.addEventListener("click", () => this.close());
@@ -292,7 +332,6 @@ export class UploadModal {
// UI Updates
const isUpload = mode === "upload";
- // Toggle Buttons
this.modeButtons.upload.setAttribute("aria-pressed", isUpload);
this.modeButtons.upload.classList.toggle("bg-surface", isUpload);
this.modeButtons.upload.classList.toggle("text-text", isUpload);
@@ -309,6 +348,10 @@ export class UploadModal {
if (isUpload) {
this.sourceSections.upload.classList.remove("hidden");
this.sourceSections.external.classList.add("hidden");
+ // Show results if we have them
+ if (this.videoUploadState.url || this.torrentState.magnet) {
+ this.sourceSections.results.classList.remove("hidden");
+ }
if (this.toggles.browseThumbnail) {
this.toggles.browseThumbnail.classList.remove("hidden");
@@ -316,14 +359,14 @@ export class UploadModal {
} else {
this.sourceSections.upload.classList.add("hidden");
this.sourceSections.external.classList.remove("hidden");
+ this.sourceSections.results.classList.add("hidden");
if (this.toggles.browseThumbnail) {
this.toggles.browseThumbnail.classList.add("hidden");
}
}
- // Update Button Text
- this.submitButton.textContent = isUpload ? "Upload & Publish" : "Publish Video";
+ this.submitButton.textContent = isUpload ? "Publish Video" : "Publish Video";
}
setupAccordion(btn, section) {
@@ -333,7 +376,6 @@ export class UploadModal {
if (isHidden) {
section.classList.remove("hidden");
btn.setAttribute("aria-expanded", "true");
- // Rotate icon if exists
const icon = btn.querySelector("svg");
if (icon) icon.classList.add("rotate-90");
} else {
@@ -402,9 +444,10 @@ export class UploadModal {
thumbnailFile.addEventListener("change", () => {
const file = thumbnailFile.files?.[0];
if (file) {
- thumbnail.value = ""; // Clear explicit URL
+ thumbnail.value = "";
thumbnail.placeholder = `Selected: ${file.name}`;
thumbnail.disabled = true;
+ this.handleThumbnailSelection(file); // Trigger upload immediately
} else {
thumbnail.placeholder = "https://example.com/thumbnail.jpg";
thumbnail.disabled = false;
@@ -412,7 +455,272 @@ export class UploadModal {
});
}
- // --- R2/Storage Integration ---
+ // --- Immediate Upload Handlers ---
+
+ async handleVideoSelection(e) {
+ const file = e.target.files?.[0];
+ if (!file) return;
+
+ if (!this.storageConfigured) {
+ alert("Please configure storage before selecting a file.");
+ e.target.value = ""; // Clear selection
+ return;
+ }
+ if (!this.isStorageUnlocked) {
+ alert("Please unlock storage before selecting a file.");
+ e.target.value = "";
+ return;
+ }
+
+ // Reset Video State
+ this.videoUploadState = {
+ status: 'uploading',
+ progress: 0,
+ url: '',
+ key: '',
+ file: file,
+ };
+ // Reset Torrent State
+ this.torrentState = {
+ status: 'pending',
+ infoHash: '',
+ magnet: '',
+ url: '',
+ file: null,
+ };
+
+ // Show UI
+ this.sourceSections.progress.classList.remove("hidden");
+ this.sourceSections.results.classList.remove("hidden");
+ this.results.videoUrl.value = "Uploading...";
+ this.results.magnet.value = "Pending...";
+ this.results.torrentUrl.value = "Pending...";
+
+ this.updateVideoProgress(0, "Preparing upload...");
+
+ try {
+ // 1. Prepare Upload (Get Creds & Bucket)
+ const pubkey = this.getCurrentPubkey();
+ const npub = this.safeEncodeNpub(pubkey);
+ const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
+ ? this.r2Service
+ : this.s3Service;
+
+ const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials });
+
+ // 2. Determine Keys
+ const videoKey = buildR2Key(npub, file);
+ const baseDomain = bucketEntry.publicBaseUrl;
+
+ // Note: buildPublicUrl works for both if the base is clean
+ // For S3, buildS3ObjectUrl might be safer if we have complex paths, but prepareUpload standardizes on publicBaseUrl
+ let videoPublicUrl = "";
+ if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
+ videoPublicUrl = buildPublicUrl(baseDomain, videoKey);
+ } else {
+ videoPublicUrl = buildS3ObjectUrl({
+ publicBaseUrl: baseDomain,
+ key: videoKey,
+ // For S3 we might need forcePathStyle if publicBaseUrl isn't set, but prepareUpload enforces it.
+ });
+ }
+
+ // 3. Start Video Upload
+ this.updateVideoProgress(0, "Uploading video...");
+ const uploadPromise = service.uploadFile({
+ file,
+ bucket: bucketEntry.bucket,
+ key: videoKey,
+ // Spread settings carefully
+ endpoint: settings.endpoint,
+ region: settings.region,
+ accessKeyId: settings.accessKeyId,
+ secretAccessKey: settings.secretAccessKey,
+ forcePathStyle: settings.forcePathStyle,
+ createBucketIfMissing: true,
+ onProgress: (fraction) => {
+ this.updateVideoProgress(fraction);
+ }
+ });
+
+ // 4. Calculate Info Hash (Parallel if possible, but JS single thread limits this.
+ // Since createTorrentMetadata reads the file, it might contend with upload read.
+ // Let's do it concurrently and hope the browser handles file I/O well.
+ this.updateVideoProgress(null, "Uploading & Calculating Hash...");
+
+ const torrentPromise = this.generateTorrentMetadata({ file, videoPublicUrl });
+
+ const [uploadResult, torrentResult] = await Promise.all([uploadPromise, torrentPromise]);
+
+ // Video Complete
+ this.videoUploadState.status = 'complete';
+ this.videoUploadState.url = videoPublicUrl;
+ this.videoUploadState.key = videoKey;
+ this.results.videoUrl.value = videoPublicUrl;
+ this.updateVideoProgress(1, "Video uploaded.");
+
+ // 5. Handle Torrent Result & Upload .torrent file
+ if (torrentResult.hasValidInfoHash && torrentResult.torrentFile) {
+ const baseKey = videoKey.replace(/\.[^/.]+$/, "");
+ const torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
+
+ let torrentPublicUrl = "";
+ if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
+ torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey);
+ } else {
+ torrentPublicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key: torrentKey });
+ }
+
+ this.updateVideoProgress(1, "Uploading torrent metadata...");
+
+ await service.uploadFile({
+ file: torrentResult.torrentFile,
+ bucket: bucketEntry.bucket,
+ key: torrentKey,
+ endpoint: settings.endpoint,
+ region: settings.region,
+ accessKeyId: settings.accessKeyId,
+ secretAccessKey: settings.secretAccessKey,
+ forcePathStyle: settings.forcePathStyle,
+ createBucketIfMissing: true,
+ });
+
+ this.torrentState.status = 'complete';
+ this.torrentState.infoHash = torrentResult.infoHash;
+ this.torrentState.url = torrentPublicUrl;
+ this.torrentState.file = torrentResult.torrentFile;
+
+ // Construct Magnet
+ const encodedDn = encodeURIComponent(file.name);
+ const encodedWs = encodeURIComponent(videoPublicUrl);
+ const encodedXs = encodeURIComponent(torrentPublicUrl);
+ const magnet = `magnet:?xt=urn:btih:${torrentResult.infoHash}&dn=${encodedDn}&ws=${encodedWs}&xs=${encodedXs}`;
+
+ this.torrentState.magnet = magnet;
+
+ this.results.magnet.value = magnet;
+ this.results.torrentUrl.value = torrentPublicUrl;
+
+ this.updateVideoProgress(1, "Ready to publish!");
+ } else {
+ this.torrentState.status = 'skipped'; // Failed hash or invalid
+ this.updateVideoProgress(1, "Upload complete (No torrent fallback).");
+ this.results.magnet.value = "Not available (Info Hash failed)";
+ this.results.torrentUrl.value = "Not available";
+ }
+
+ } catch (err) {
+ userLogger.error("Video upload sequence failed:", err);
+ this.videoUploadState.status = 'error';
+ this.updateVideoProgress(null, "Upload failed.");
+ alert(`Upload failed: ${err.message}`);
+
+ this.sourceSections.progress.classList.add("hidden");
+ this.inputs.file.value = ""; // Reset
+ }
+ }
+
+ async handleThumbnailSelection(file) {
+ if (!this.storageConfigured || !this.isStorageUnlocked) {
+ // We can't upload yet. Just hold it in state or warn?
+ // Since UI disables the browse button until unlocked (mostly), we assume safe.
+ // But if they just unlocked, we're good.
+ return;
+ }
+
+ this.thumbnailUploadState = {
+ status: 'uploading',
+ progress: 0,
+ url: '',
+ key: '',
+ file: file,
+ };
+
+ this.sourceSections.thumbnailProgress.classList.remove("hidden");
+ this.updateThumbnailProgress(0, "Uploading thumbnail...");
+
+ try {
+ const pubkey = this.getCurrentPubkey();
+ const npub = this.safeEncodeNpub(pubkey);
+ const service = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2"
+ ? this.r2Service
+ : this.s3Service;
+
+ const { settings, bucketEntry } = await service.prepareUpload(npub, { credentials: this.activeCredentials });
+
+ // Derive Key (randomish or based on file)
+ // We don't have the video key here easily if video isn't selected yet.
+ // Use a standalone key structure: npub/thumbnails/timestamp-name
+ const timestamp = Date.now();
+ const cleanName = file.name.replace(/[^a-zA-Z0-9.-]/g, "_");
+ const key = `${npub}/thumbnails/${timestamp}-${cleanName}`;
+
+ const baseDomain = bucketEntry.publicBaseUrl;
+ let publicUrl = "";
+ if (this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2") {
+ publicUrl = buildPublicUrl(baseDomain, key);
+ } else {
+ publicUrl = buildS3ObjectUrl({ publicBaseUrl: baseDomain, key });
+ }
+
+ await service.uploadFile({
+ file,
+ bucket: bucketEntry.bucket,
+ key,
+ endpoint: settings.endpoint,
+ region: settings.region,
+ accessKeyId: settings.accessKeyId,
+ secretAccessKey: settings.secretAccessKey,
+ forcePathStyle: settings.forcePathStyle,
+ createBucketIfMissing: true,
+ onProgress: (fraction) => this.updateThumbnailProgress(fraction)
+ });
+
+ this.thumbnailUploadState.status = 'complete';
+ this.thumbnailUploadState.url = publicUrl;
+ this.inputs.thumbnail.value = publicUrl;
+
+ this.updateThumbnailProgress(1, "Thumbnail uploaded.");
+
+ // Hide progress after a delay
+ setTimeout(() => {
+ this.sourceSections.thumbnailProgress.classList.add("hidden");
+ }, 2000);
+
+ } catch (err) {
+ userLogger.error("Thumbnail upload failed:", err);
+ this.thumbnailUploadState.status = 'error';
+ this.updateThumbnailProgress(null, "Failed.");
+ alert("Thumbnail upload failed.");
+ }
+ }
+
+
+ updateVideoProgress(fraction, text) {
+ if (text) this.statusText.uploadMain.textContent = text;
+
+ if (fraction === null) {
+ // Indeterminate or error
+ return;
+ }
+
+ const pct = Math.round(fraction * 100);
+ this.inputs.progress.value = pct;
+ this.statusText.uploadPercent.textContent = `${pct}%`;
+ }
+
+ updateThumbnailProgress(fraction, text) {
+ if (text) this.statusText.thumbnailMain.textContent = text;
+
+ if (fraction === null) return;
+
+ const pct = Math.round(fraction * 100);
+ this.inputs.thumbnailProgress.value = pct;
+ this.statusText.thumbnailPercent.textContent = `${pct}%`;
+ }
+
+
+ // --- R2/Storage Integration (View Only now) ---
async refreshState() {
if (this.storageService) {
@@ -581,56 +889,27 @@ export class UploadModal {
return "Uploads will target your S3-compatible bucket.";
}
- registerStorageSubscriptions() {
- const services = [this.r2Service, this.s3Service].filter(
- (service) => service && typeof service.on === "function"
- );
-
- // Clear old
- this.r2Unsubscribes.forEach(u => u && u());
- this.r2Unsubscribes = [];
-
- services.forEach((service) => {
- const sub = (evt, fn) => {
- const unsub = service.on(evt, fn);
- if (unsub) this.r2Unsubscribes.push(unsub);
- };
-
- sub("uploadProgress", ({ fraction }) => this.updateProgress(fraction));
- sub("uploadStatus", ({ message, variant }) => this.updateUploadStatus(message, variant));
- sub("uploadStateChange", ({ isUploading }) => {
- this.isUploading = isUploading;
- this.submitButton.disabled = isUploading;
- });
- });
- }
-
- updateProgress(fraction) {
- const container = this.sourceSections.progress;
- const bar = this.inputs.progress;
- const txt = this.statusText.uploadPercent;
-
- if (fraction === null || fraction < 0 || isNaN(fraction)) {
- container.classList.add("hidden");
- return;
- }
-
- container.classList.remove("hidden");
- const pct = Math.round(fraction * 100);
- bar.value = pct;
- txt.textContent = `${pct}%`;
- }
-
- updateUploadStatus(msg, variant) {
- if (this.statusText.uploadMain) {
- this.statusText.uploadMain.textContent = msg;
- }
- }
// --- Submission ---
async handleSubmit() {
- if (this.isUploading) return;
+ // Check upload state
+ if (this.activeSource === "upload") {
+ if (this.videoUploadState.status === 'uploading' || this.thumbnailUploadState.status === 'uploading') {
+ alert("Please wait for uploads to complete.");
+ return;
+ }
+ if (this.videoUploadState.status === 'error') {
+ alert("Video upload failed. Please try again.");
+ return;
+ }
+ if (this.videoUploadState.status !== 'complete') {
+ // Fallback: If no file selected, maybe they want to submit without a new file?
+ // (Not supported in this simplified modal, assume file required)
+ alert("Please select a video file and wait for it to upload.");
+ return;
+ }
+ }
const audienceFlags = {
isNsfw: this.toggles.nsfw?.checked || false,
@@ -660,219 +939,38 @@ export class UploadModal {
try {
if (this.activeSource === "upload") {
- await this.handleUploadFlow(metadata);
+ // We already have the URLs in state
+ metadata.url = this.videoUploadState.url;
+ metadata.magnet = this.torrentState.magnet || "";
+ metadata.ws = this.videoUploadState.url; // WebSeed is same as R2 URL
+ metadata.xs = this.torrentState.url || "";
+
+ // NIP-71 IMETA for the main video
+ // We construct a primary imeta entry for the uploaded file
+ const imeta = {
+ url: this.videoUploadState.url,
+ m: this.videoUploadState.file?.type || "video/mp4",
+ x: this.torrentState.infoHash || "",
+ };
+
+ // Merge with user provided imeta if any
+ if (!metadata.nip71) metadata.nip71 = {};
+ if (!metadata.nip71.imeta) metadata.nip71.imeta = [];
+ metadata.nip71.imeta.unshift(imeta);
+
+ await this.publish(metadata);
+
} else {
await this.handleExternalFlow(metadata);
}
} catch (err) {
- userLogger.error("Upload failed", err);
+ userLogger.error("Submission failed", err);
this.showError(err.message || "An unexpected error occurred.");
}
}
- async handleUploadFlow(metadata) {
- const file = this.inputs.file?.files?.[0];
- if (!file) throw new Error("Please select a video file to upload.");
-
- if (!this.storageConfigured) throw new Error("Please configure storage first.");
- if (!this.activeCredentials) throw new Error("Storage is locked. Please unlock it to upload.");
-
- // Check if we have R2 or Generic S3 credentials
- const isR2 = this.activeProvider === PROVIDERS.R2 || this.activeProvider === "cloudflare_r2";
-
- return isR2
- ? this.handleR2UploadFlow(metadata, file)
- : this.handleS3UploadFlow(metadata, file);
- }
-
- async handleR2UploadFlow(metadata, file) {
- const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
- const settings = this.activeCredentials;
-
- const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
- if (!pubkey) throw new Error("Please login to publish.");
-
- const npub = this.safeEncodeNpub(pubkey);
-
- let videoKey = "";
- let videoPublicUrl = "";
- let torrentKey = "";
- let torrentPublicUrl = "";
-
- // Validate baseDomain
- const baseDomain = settings.baseDomain || settings.meta?.baseDomain || settings.meta?.prefix;
- if (!baseDomain) {
- throw new Error("Missing Public URL (Base Domain) in settings.");
- }
-
- try {
- videoKey = buildR2Key(npub, file);
- videoPublicUrl = buildPublicUrl(baseDomain, videoKey);
-
- const baseKey = videoKey.replace(/\.[^/.]+$/, "");
- torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
- torrentPublicUrl = buildPublicUrl(baseDomain, torrentKey);
- } catch (prepErr) {
- userLogger.warn("Failed to pre-calculate R2 keys:", prepErr);
- }
-
- const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({
- file,
- videoPublicUrl,
- });
-
- if (!hasValidInfoHash) {
- const proceed = confirm(
- "We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?"
- );
- if (!proceed) {
- this.updateUploadStatus(
- "Upload canceled. A valid info hash is required for WebTorrent fallback.",
- "warning"
- );
- this.isUploading = false;
- this.submitButton.disabled = false;
- this.updateProgress(null);
- return;
- }
- this.updateUploadStatus(
- "Continuing without WebTorrent fallback (info hash unavailable).",
- "warning"
- );
- }
-
- // Reconstruct simple settings object expected by r2Service if needed
- // r2Service expects: { accountId, accessKeyId, secretAccessKey, baseDomain }
- // Our stored credential object has flat properties for keys.
- const uploadSettings = {
- accountId: settings.accountId,
- accessKeyId: settings.accessKeyId,
- secretAccessKey: settings.secretAccessKey,
- baseDomain: baseDomain,
- };
-
- await this.r2Service.uploadVideo({
- npub,
- file,
- thumbnailFile,
- torrentFile,
- metadata,
- infoHash: hasValidInfoHash ? infoHash : "",
- settingsInput: null, // No manual input
- explicitCredentials: uploadSettings,
- publishVideoNote: this.publishVideoNote,
- onReset: () => this.resetForm(),
- forcedVideoKey: videoKey,
- forcedVideoUrl: videoPublicUrl,
- forcedTorrentKey: torrentKey,
- forcedTorrentUrl: torrentPublicUrl,
- });
- }
-
- async handleS3UploadFlow(metadata, file) {
- const thumbnailFile = this.inputs.thumbnailFile?.files?.[0];
- const settings = this.activeCredentials;
-
- if (!this.s3Service) throw new Error("S3 upload service is unavailable.");
-
- const pubkey = this.getCurrentPubkey ? this.getCurrentPubkey() : null;
- if (!pubkey) throw new Error("Please login to publish.");
-
- const npub = this.safeEncodeNpub(pubkey);
-
- // Reconstruct settings object for s3Service
- // s3Service expects: { endpoint, region, bucket, accessKeyId, secretAccessKey, publicBaseUrl, forcePathStyle }
- const uploadSettings = {
- endpoint: settings.endpoint || settings.meta?.endpoint,
- region: settings.region || settings.meta?.region || "auto",
- bucket: settings.bucket || settings.meta?.bucket,
- accessKeyId: settings.accessKeyId,
- secretAccessKey: settings.secretAccessKey,
- publicBaseUrl: settings.meta?.publicBaseUrl,
- forcePathStyle: settings.forcePathStyle, // might be boolean in root or meta?
- };
-
- // Ensure forcePathStyle is boolean
- if (typeof uploadSettings.forcePathStyle !== 'boolean') {
- uploadSettings.forcePathStyle = !!settings.meta?.forcePathStyle;
- }
-
- let videoKey = "";
- let videoPublicUrl = "";
- let torrentKey = "";
- let torrentPublicUrl = "";
-
- try {
- videoKey = buildR2Key(npub, file);
- videoPublicUrl = buildS3ObjectUrl({
- publicBaseUrl: uploadSettings.publicBaseUrl,
- endpoint: uploadSettings.endpoint,
- bucket: uploadSettings.bucket,
- key: videoKey,
- forcePathStyle: uploadSettings.forcePathStyle,
- });
- const baseKey = videoKey.replace(/\.[^/.]+$/, "");
- torrentKey = (baseKey && baseKey !== videoKey) ? `${baseKey}.torrent` : `${videoKey}.torrent`;
- torrentPublicUrl = buildS3ObjectUrl({
- publicBaseUrl: uploadSettings.publicBaseUrl,
- endpoint: uploadSettings.endpoint,
- bucket: uploadSettings.bucket,
- key: torrentKey,
- forcePathStyle: uploadSettings.forcePathStyle,
- });
- } catch (prepErr) {
- userLogger.warn("Failed to pre-calculate S3 keys:", prepErr);
- }
-
- const { infoHash, torrentFile, hasValidInfoHash } = await this.generateTorrentMetadata({
- file,
- videoPublicUrl,
- });
-
- if (!hasValidInfoHash) {
- const proceed = confirm(
- "We couldn't calculate a valid info hash. Publishing will continue with URL-first playback only, and WebTorrent fallback will be unavailable. Continue?"
- );
- if (!proceed) {
- this.updateUploadStatus(
- "Upload canceled. A valid info hash is required for WebTorrent fallback.",
- "warning"
- );
- this.isUploading = false;
- this.submitButton.disabled = false;
- this.updateProgress(null);
- return;
- }
- this.updateUploadStatus(
- "Continuing without WebTorrent fallback (info hash unavailable).",
- "warning"
- );
- }
-
- await this.s3Service.uploadVideo({
- npub,
- file,
- thumbnailFile,
- torrentFile,
- metadata,
- infoHash: hasValidInfoHash ? infoHash : "",
- settings: uploadSettings,
- createBucketIfMissing: !!settings.meta?.createBucketIfMissing, // Use meta flag if present
- publishVideoNote: this.publishVideoNote,
- onReset: () => this.resetForm(),
- forcedVideoKey: videoKey,
- forcedVideoUrl: videoPublicUrl,
- forcedTorrentKey: torrentKey,
- forcedTorrentUrl: torrentPublicUrl,
- });
- }
-
async generateTorrentMetadata({ file, videoPublicUrl } = {}) {
- this.isUploading = true;
- this.submitButton.disabled = true;
- this.updateUploadStatus("Calculating Info Hash...", "info");
- this.updateProgress(0);
-
+ // Helper used by immediate upload flow
let infoHash = "";
let torrentFile = null;
@@ -917,13 +1015,16 @@ export class UploadModal {
if (!confirm("Magnet-only uploads require active seeding. Proceed?")) return;
}
+ await this.publish(metadata);
+ }
+
+ async publish(metadata) {
// Normalize & Publish
const { payload, errors } = normalizeVideoNotePayload(metadata);
if (errors.length) {
throw new Error(getVideoNoteErrorMessage(errors[0]));
}
- // Simulate async publish
this.submitButton.disabled = true;
this.submitButton.textContent = "Publishing...";
@@ -948,7 +1049,15 @@ export class UploadModal {
this.toggles.comments.checked = true;
this.toggles.nsfw.checked = false;
this.toggles.kids.checked = false;
- this.updateProgress(null);
+
+ // Reset State
+ this.videoUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null };
+ this.thumbnailUploadState = { status: 'idle', progress: 0, url: '', key: '', file: null };
+ this.torrentState = { status: 'idle', infoHash: '', magnet: '', url: '', file: null };
+
+ this.sourceSections.progress.classList.add("hidden");
+ this.sourceSections.thumbnailProgress.classList.add("hidden");
+ this.sourceSections.results.classList.add("hidden");
// Reset thumbnail UI
if (this.inputs.thumbnail) {