Reels
Checking API
Swipe up for next Scroll / drag for next

Upload a reel

return; } const dy = t.clientY - touchStartY; const dx = t.clientX - touchStartX; touchStartY = null; touchStartX = null; if (Math.abs(dy) > 50 && Math.abs(dy) > Math.abs(dx)) { move(dy < 0 ? 1 : -1); } }, { passive: true } ); } elements.likeButton.addEventListener("click", async () => { const reel = reels[currentIndex]; if (!reel) return; if (liked.has(reel.id)) { showToast("Already liked from this device"); return; } // Optimistic UI — server only counts once per device elements.likeButton.setAttribute("aria-pressed", "true"); elements.likeIcon.textContent = "♥"; elements.likeCount.textContent = (reel.likes ?? 0) + 1; try { const data = await fetch(`${API}/api/videos/${encodeURIComponent(reel.id)}/like`, { method: "POST", headers: { "Content-Type": "application/json", "X-Device-Id": getDeviceId(), }, body: JSON.stringify({ deviceId: getDeviceId() }), }).then(readJson); markLiked(reel.id); if (data.counted) { reel.likes = (reel.likes ?? 0) + 1; showToast("Liked"); } else { // Already counted server-side; keep heart, don't bump again elements.likeCount.textContent = reel.likes ?? 0; showToast("Already liked from this device"); } } catch (error) { elements.likeButton.setAttribute("aria-pressed", "false"); elements.likeIcon.textContent = "♡"; elements.likeCount.textContent = reel.likes ?? 0; showToast(error instanceof Error ? error.message : "Couldn't like"); } }); elements.shareButton.addEventListener("click", async () => { const reel = reels[currentIndex]; if (!reel) return; const shareUrl = `${location.origin}${location.pathname}?v=${encodeURIComponent(reel.id)}`; // Always allow sharing the link; only count once per device if (!shared.has(reel.id)) { try { const data = await fetch(`${API}/api/videos/${encodeURIComponent(reel.id)}/share`, { method: "POST", headers: { "Content-Type": "application/json", "X-Device-Id": getDeviceId(), }, body: JSON.stringify({ deviceId: getDeviceId() }), }).then(readJson); markShared(reel.id); if (data.counted) { reel.shares = (reel.shares ?? 0) + 1; elements.shareCount.textContent = reel.shares; } } catch { // Link share still proceeds even if count fails } } try { if (navigator.share) { await navigator.share({ title: reel.title, url: shareUrl }); return; } } catch {} try { await navigator.clipboard.writeText(shareUrl); showToast(shared.has(reel.id) ? "Share link copied" : "Share link copied"); } catch { showToast("Share link ready"); } }); document.querySelectorAll("[data-toast]").forEach((button) => { button.addEventListener("click", () => { showToast(button.dataset.toast || "Action unavailable"); }); }); elements.unmuteButton.addEventListener("click", () => { elements.video.muted = false; elements.video.play().catch(() => {}); elements.unmuteButton.style.display = "none"; }); // Update visibility of the unmute button if audio is forced off elements.video.addEventListener("volumechange", () => { if (elements.video.muted || elements.video.volume === 0) { elements.unmuteButton.style.display = "block"; } else { elements.unmuteButton.style.display = "none"; } }); // ---------- Comments ---------- function anySheetOpen() { return ( elements.uploadOverlay.dataset.open === "true" || elements.commentsOverlay.dataset.open === "true" ); } function renderComments(items) { if (!items?.length) { elements.commentsList.innerHTML = `

No comments yet. Be the first.

`; return; } elements.commentsList.innerHTML = items .map( (c) => `
@${escapeHtml(c.author || "Anonymous")} ${escapeHtml(formatCommentTime(c.createdAt))}

${escapeHtml(c.text)}

` ) .join(""); } async function loadComments(videoId) { if (!videoId) return; commentsLoading = true; elements.commentsList.innerHTML = `

Loading comments…

`; try { const data = await fetch(`${API}/api/videos/${encodeURIComponent(videoId)}/comments?perPage=50`).then(readJson); renderComments(data.items || []); const total = data.total ?? data.items?.length ?? 0; const reel = reels[currentIndex]; if (reel && reel.id === videoId) { reel.comments = total; elements.commentCount.textContent = total; } } catch (error) { elements.commentsList.innerHTML = `

${escapeHtml( error instanceof Error ? error.message : "Failed to load comments" )}

`; } finally { commentsLoading = false; } } function openComments() { const reel = reels[currentIndex]; if (!reel) { showToast("No reel selected"); return; } const savedAuthor = localStorage.getItem(COMMENT_AUTHOR_KEY); if (savedAuthor) elements.commentAuthor.value = savedAuthor; else if (elements.uploaderInput.value) elements.commentAuthor.value = elements.uploaderInput.value; elements.commentText.value = ""; const already = commented.has(reel.id); elements.commentSubmit.disabled = already; elements.commentText.disabled = already; elements.commentText.placeholder = already ? "You already commented from this device" : "Say something…"; elements.commentsOverlay.dataset.open = "true"; elements.video.pause(); loadComments(reel.id); if (!already) window.setTimeout(() => elements.commentText.focus(), 50); else showToast("You already commented from this device"); } function closeComments() { elements.commentsOverlay.dataset.open = "false"; } elements.commentButton.addEventListener("click", openComments); elements.closeComments.addEventListener("click", closeComments); elements.commentsOverlay.addEventListener("click", (event) => { if (event.target === elements.commentsOverlay) closeComments(); }); elements.commentForm.addEventListener("submit", async (event) => { event.preventDefault(); const reel = reels[currentIndex]; if (!reel) return; if (commented.has(reel.id)) { showToast("You already commented from this device"); return; } const text = elements.commentText.value.trim(); const author = elements.commentAuthor.value.trim() || "Anonymous"; if (!text) { showToast("Write a comment first"); return; } elements.commentSubmit.disabled = true; try { const data = await fetch(`${API}/api/videos/${encodeURIComponent(reel.id)}/comments`, { method: "POST", headers: { "Content-Type": "application/json", "X-Device-Id": getDeviceId(), }, body: JSON.stringify({ author, text, deviceId: getDeviceId() }), }).then(readJson); localStorage.setItem(COMMENT_AUTHOR_KEY, author); markCommented(reel.id); elements.commentText.value = ""; elements.commentSubmit.disabled = true; reel.comments = (reel.comments ?? 0) + 1; elements.commentCount.textContent = reel.comments; // Prepend optimistically, then refresh list if (data.data) { const existing = elements.commentsList.querySelector(".comments-empty"); if (existing) elements.commentsList.innerHTML = ""; const item = data.data; elements.commentsList.insertAdjacentHTML( "afterbegin", `
@${escapeHtml(item.author || author)} just now

${escapeHtml(item.text || text)}

` ); } else { await loadComments(reel.id); } showToast("Comment posted"); elements.commentText.disabled = true; elements.commentText.placeholder = "You already commented from this device"; } catch (error) { const msg = error instanceof Error ? error.message : "Couldn't post comment"; if (/already commented/i.test(msg)) { markCommented(reel.id); elements.commentText.disabled = true; elements.commentSubmit.disabled = true; elements.commentText.placeholder = "You already commented from this device"; } showToast(msg); } finally { const locked = reel && commented.has(reel.id); elements.commentSubmit.disabled = locked; if (locked) elements.commentText.disabled = true; else elements.commentSubmit.disabled = false; } }); // ---------- Delete ---------- elements.deleteButton.addEventListener("click", async () => { const reel = reels[currentIndex]; if (!reel) { showToast("No reel selected"); return; } const ownerToken = getOwnerToken(reel.id); if (!ownerToken) { showToast("Delete only works for reels you uploaded on this device"); return; } const ok = window.confirm(`Delete “${reel.title || "this reel"}”? This cannot be undone.`); if (!ok) return; elements.deleteButton.disabled = true; try { const deleteUrl = `${API}/api/videos/${encodeURIComponent(reel.id)}?ownerToken=${encodeURIComponent(ownerToken)}`; await fetch(deleteUrl, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ownerToken }), }).then(readJson); removeOwnerToken(reel.id); reels.splice(currentIndex, 1); if (currentIndex >= reels.length) currentIndex = Math.max(0, reels.length - 1); closeComments(); showToast("Reel deleted"); renderReel(); elements.previousButton.disabled = reels.length < 2; elements.nextButton.disabled = reels.length < 2; } catch (error) { showToast(error instanceof Error ? error.message : "Couldn't delete reel"); } finally { elements.deleteButton.disabled = false; } }); elements.nextButton.addEventListener("click", () => move(1)); elements.previousButton.addEventListener("click", () => move(-1)); elements.bottomPanel?.addEventListener("click", (event) => { event.stopPropagation(); togglePanelExpanded(); }); elements.bottomPanel?.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); event.stopPropagation(); togglePanelExpanded(); } }); window.addEventListener("keydown", (event) => { if (anySheetOpen()) { if (event.key === "Escape") { closeUploadSheet(); closeComments(); } return; } if (event.key === "Escape" && elements.bottomPanel?.dataset.expanded === "true") { setPanelExpanded(false); return; } if (event.key === "ArrowDown") move(1); if (event.key === "ArrowUp") move(-1); }); // ---------- Upload ---------- let isUploading = false; function fileFingerprint(file) { if (!file) return ""; return `${file.name}|${file.size}|${file.lastModified}|${file.type || ""}`; } function loadUploadedFiles() { try { const raw = JSON.parse(localStorage.getItem(UPLOADED_FILES_KEY) || "[]"); return new Set(Array.isArray(raw) ? raw.map(String) : []); } catch { return new Set(); } } function markFileUploaded(fingerprint) { if (!fingerprint) return; const set = loadUploadedFiles(); set.add(fingerprint); // Cap list so localStorage doesn't grow forever const list = [...set]; while (list.length > 200) list.shift(); localStorage.setItem(UPLOADED_FILES_KEY, JSON.stringify(list)); } function isFileAlreadyUploaded(file) { const fp = fileFingerprint(file); return Boolean(fp && loadUploadedFiles().has(fp)); } function setUploadFormBusy(busy) { isUploading = busy; const fields = [ elements.videoFile, elements.titleInput, elements.descriptionInput, elements.uploaderInput, elements.categoryInput, elements.hashtagsInput, elements.uploadSubmit, elements.closeUpload, ]; for (const field of fields) { if (field) field.disabled = busy; } if (elements.openUpload) { elements.openUpload.disabled = busy; elements.openUpload.setAttribute("aria-busy", String(busy)); } if (elements.uploadSubmit) { elements.uploadSubmit.textContent = busy ? "Uploading…" : "Upload"; } } function openUploadSheet() { if (isUploading) { showToast("Upload already in progress"); return; } closeComments(); const savedAuthor = localStorage.getItem(COMMENT_AUTHOR_KEY); if (savedAuthor) elements.uploaderInput.value = savedAuthor; elements.uploadOverlay.dataset.open = "true"; elements.video.pause(); } function closeUploadSheet() { if (isUploading) { showToast("Please wait — upload in progress"); return; } elements.uploadOverlay.dataset.open = "false"; } elements.openUpload.addEventListener("click", openUploadSheet); elements.closeUpload.addEventListener("click", closeUploadSheet); elements.uploadOverlay.addEventListener("click", (event) => { if (event.target === elements.uploadOverlay) closeUploadSheet(); }); function setUploadStatus(kind, message) { elements.uploadStatus.dataset.kind = kind; elements.uploadStatus.textContent = message; } elements.videoFile.addEventListener("change", () => { const file = elements.videoFile.files?.[0]; if (!file) return; if (!isUploading) { setUploadStatus("", ""); } }); let wakeLock = null; async function requestWakeLock() { try { wakeLock = await navigator.wakeLock?.request("screen"); } catch {} } function releaseWakeLock() { if (wakeLock) { try { wakeLock.release(); } catch {} wakeLock = null; } } /** Handles the common case (clip fits in one request): a single * multipart POST straight to /api/videos, no KV round trips at * all. Returns true if it handled the upload (success or a * terminal failure already reported to the user), false if the * caller should fall back to the chunked path. */ async function tryDirectUpload(file, fingerprint) { setUploadStatus("info", `Uploading ${(file.size / 1048576).toFixed(1)}MB…`); elements.progressTrack.dataset.visible = "true"; elements.progressFill.style.width = "40%"; const formData = new FormData(); formData.append("video", file); formData.append("title", elements.titleInput.value); formData.append("description", elements.descriptionInput.value); formData.append("uploader", elements.uploaderInput.value); formData.append("category", elements.categoryInput.value); formData.append("hashtags", elements.hashtagsInput.value); try { const resp = await fetch(`${API}/api/videos`, { method: "POST", body: formData }); const compData = await readJson(resp); elements.progressFill.style.width = "100%"; elements.progressTrack.dataset.visible = "false"; const uploaded = compData.data; if (uploaded?.id && uploaded?.ownerToken) { saveOwnerToken(uploaded.id, uploaded.ownerToken); } markFileUploaded(fingerprint); if (elements.uploaderInput.value.trim()) { localStorage.setItem(COMMENT_AUTHOR_KEY, elements.uploaderInput.value.trim()); } setUploadStatus("success", "Uploaded! Processing in the background…"); elements.uploadForm.reset(); elements.uploaderInput.value = "demo-user"; elements.categoryInput.value = "entertainment"; showToast("Reel uploaded"); setTimeout(async () => { setUploadFormBusy(false); closeUploadSheet(); await loadFeed(); }, 1200); return true; } catch (error) { elements.progressTrack.dataset.visible = "false"; setUploadStatus("error", `Error: ${error instanceof Error ? error.message : "unknown"}`); setUploadFormBusy(false); return true; // terminal failure, already reported — don't fall through to chunking } finally { releaseWakeLock(); } } elements.uploadForm.addEventListener("submit", async (event) => { event.preventDefault(); if (isUploading) { showToast("Upload already in progress"); return; } const file = elements.videoFile.files[0]; if (!file) { setUploadStatus("error", "Please select a video file"); return; } const fingerprint = fileFingerprint(file); if (isFileAlreadyUploaded(file)) { setUploadStatus("error", "This file was already uploaded from this device. Choose another video."); elements.videoFile.value = ""; showToast("Same file already uploaded"); return; } setUploadFormBusy(true); await requestWakeLock(); if (file.size <= DIRECT_UPLOAD_MAX_BYTES) { await tryDirectUpload(file, fingerprint); return; } const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const sizeMB = (file.size / 1048576).toFixed(1); setUploadStatus("info", `Starting upload (${totalChunks} chunk${totalChunks === 1 ? "" : "s"}, ${Math.round(CHUNK_SIZE / 1048576)}MB each)…`); elements.progressTrack.dataset.visible = "true"; elements.progressFill.style.width = "0%"; let uploadId; try { const initData = await fetch(`${API}/api/videos/init-upload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: elements.titleInput.value, description: elements.descriptionInput.value, uploader: elements.uploaderInput.value, category: elements.categoryInput.value, hashtags: elements.hashtagsInput.value, fileName: file.name, fileSize: file.size, mimeType: file.type, totalChunks, }), }).then(readJson); if (!initData.uploadId) throw new Error("Init failed"); uploadId = initData.uploadId; } catch (error) { setUploadStatus("error", `Init error: ${error instanceof Error ? error.message : "unknown"}`); releaseWakeLock(); setUploadFormBusy(false); return; } let totalLoaded = 0; const uploadedChunks = new Set(); async function uploadOneChunk(index, chunkBlob) { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), CHUNK_TIMEOUT_MS); try { const res = await fetch( `${API}/api/videos/upload-chunk?uploadId=${encodeURIComponent(uploadId)}&index=${index - 1}`, { method: "POST", headers: { "Content-Type": "application/octet-stream" }, // Raw binary — no base64 (smaller + faster on Workers) body: chunkBlob, signal: controller.signal, } ); clearTimeout(timeoutId); let data = {}; try { data = await res.json(); } catch { data = {}; } if (!res.ok || !data.success) { throw new Error(data.message || data.error || `HTTP ${res.status}`); } if (!uploadedChunks.has(index)) { uploadedChunks.add(index); totalLoaded += chunkBlob.size; const percent = Math.min(Math.round((totalLoaded / file.size) * 100), 100); elements.progressFill.style.width = `${percent}%`; setUploadStatus( "info", `Uploading ${sizeMB}MB… ${percent}% · chunk ${index}/${totalChunks}` ); } return true; } catch (err) { clearTimeout(timeoutId); throw err; } } async function uploadWithRetry(index, chunkBlob, errors) { for (let attempt = 0; attempt < CHUNK_RETRIES; attempt++) { try { return await uploadOneChunk(index, chunkBlob); } catch (err) { const label = err?.name === "AbortError" ? `timeout(${index})` : err instanceof Error ? err.message : String(err); errors.push(`${label}#${attempt + 1}`); // Exponential backoff: 1s, 2s, 4s… if (attempt < CHUNK_RETRIES - 1) { await new Promise((r) => setTimeout(r, 1000 * 2 ** attempt)); } } } return false; } for (let start = 0; start < totalChunks; start += CHUNK_PARALLEL) { const end = Math.min(start + CHUNK_PARALLEL, totalChunks); const tasks = []; const errors = []; for (let j = start; j < end; j++) { const idx = j + 1; const chunkBlob = file.slice(j * CHUNK_SIZE, Math.min((j + 1) * CHUNK_SIZE, file.size)); tasks.push(uploadWithRetry(idx, chunkBlob, errors).then((ok) => ({ idx, ok }))); } const results = await Promise.all(tasks); const failed = results.filter((r) => !r.ok); if (failed.length > 0) { setUploadStatus( "error", `Chunk ${failed[0].idx} failed: ${errors.slice(-6).join(" | ")}. Tap Upload to retry.` ); releaseWakeLock(); setUploadFormBusy(false); return; } } setUploadStatus("info", "Completing upload…"); try { const compData = await fetch(`${API}/api/videos/complete-upload`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ uploadId }), }).then(readJson); elements.progressTrack.dataset.visible = "false"; if (!compData.success) { setUploadStatus("error", `Processing failed: ${compData.message || "unknown"}`); releaseWakeLock(); setUploadFormBusy(false); return; } const uploaded = compData.data; if (uploaded?.id && uploaded?.ownerToken) { saveOwnerToken(uploaded.id, uploaded.ownerToken); } markFileUploaded(fingerprint); if (elements.uploaderInput.value.trim()) { localStorage.setItem(COMMENT_AUTHOR_KEY, elements.uploaderInput.value.trim()); } setUploadStatus("success", "Uploaded! Processing in the background…"); elements.uploadForm.reset(); elements.uploaderInput.value = "demo-user"; elements.categoryInput.value = "entertainment"; showToast("Reel uploaded"); setTimeout(async () => { setUploadFormBusy(false); closeUploadSheet(); await loadFeed(); }, 1200); } catch (error) { elements.progressTrack.dataset.visible = "false"; setUploadStatus("error", `Error: ${error instanceof Error ? error.message : "unknown"}`); setUploadFormBusy(false); } finally { releaseWakeLock(); } }); // Initialize checkHealth(); loadFeed(); })();