diff --git a/components/video-modal.html b/components/video-modal.html
index c66f96e6..5b63b7c3 100644
--- a/components/video-modal.html
+++ b/components/video-modal.html
@@ -133,7 +133,10 @@
-
just now
+
+ just now
+ – views
+
Initializing... Just give it a sec.
diff --git a/css/style.css b/css/style.css
index 65cdbe6c..3f61eb93 100644
--- a/css/style.css
+++ b/css/style.css
@@ -885,6 +885,27 @@ header img {
box-shadow: inset 0 1px 0 rgba(15, 23, 42, 0.35);
}
+.modal-meta {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.modal-view-count {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.25rem;
+ font-size: 0.875rem;
+ line-height: 1.25rem;
+ color: #9ca3af;
+ white-space: nowrap;
+}
+
+.view-count-text {
+ white-space: nowrap;
+ color: #6b7280;
+}
+
html.modal-open {
overflow: hidden;
}
diff --git a/js/app.js b/js/app.js
index 55af3fef..49ad2aaa 100644
--- a/js/app.js
+++ b/js/app.js
@@ -42,6 +42,13 @@ import {
} from "./storage/r2-s3.js";
import { initQuickR2Upload } from "./r2-quick.js";
import { createWatchHistoryRenderer } from "./historyView.js";
+import {
+ initViewCounter,
+ subscribeToVideoViewCount,
+ unsubscribeFromVideoViewCount,
+ formatViewCount,
+ ingestLocalViewEvent,
+} from "./viewCounter.js";
function truncateMiddle(text, maxLength = 72) {
if (!text || typeof text !== "string") {
@@ -716,6 +723,9 @@ class bitvidApp {
this.videoList = null;
this._videoListElement = null;
this._videoListClickHandler = null;
+ this.viewCountSubscriptions = new Map();
+ this.modalViewCountUnsub = null;
+ this.videoViewCountEl = null;
// Videos stored as a Map (key=event.id)
this.videosMap = new Map();
@@ -1241,6 +1251,12 @@ class bitvidApp {
// 4. Connect to Nostr
await nostrClient.init();
+ try {
+ initViewCounter({ nostrClient });
+ } catch (error) {
+ console.warn("Failed to initialize view counter:", error);
+ }
+
try {
await accessControl.refresh();
} catch (error) {
@@ -1363,6 +1379,9 @@ class bitvidApp {
lastScrollY = currentScrollY;
});
+ this.videoViewCountEl =
+ document.getElementById("videoViewCount") || null;
+
console.log("Video modal initialization successful");
return true;
} catch (error) {
@@ -1386,6 +1405,8 @@ class bitvidApp {
this.videoTitle = document.getElementById("videoTitle") || null;
this.videoDescription = document.getElementById("videoDescription") || null;
this.videoTimestamp = document.getElementById("videoTimestamp") || null;
+ this.videoViewCountEl =
+ document.getElementById("videoViewCount") || null;
// The two elements we want to make clickable
this.creatorAvatar = document.getElementById("creatorAvatar") || null;
@@ -2649,6 +2670,228 @@ class bitvidApp {
return "";
}
+ deriveVideoPointerInfo(video) {
+ if (!video || typeof video !== "object") {
+ return null;
+ }
+
+ const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
+ const normalizedPubkey =
+ typeof video.pubkey === "string" ? video.pubkey.trim() : "";
+ const kind =
+ typeof video.kind === "number" && Number.isFinite(video.kind)
+ ? video.kind
+ : VIDEO_EVENT_KIND;
+
+ if (dTagValue && normalizedPubkey) {
+ const pointer = ["a", `${kind}:${normalizedPubkey}:${dTagValue}`];
+ const key = pointerArrayToKey(pointer);
+ if (key) {
+ return { pointer, key };
+ }
+ }
+
+ const fallbackId =
+ typeof video.id === "string" && video.id.trim()
+ ? video.id.trim()
+ : "";
+ if (fallbackId) {
+ const pointer = ["e", fallbackId];
+ const key = pointerArrayToKey(pointer);
+ if (key) {
+ return { pointer, key };
+ }
+ }
+
+ return null;
+ }
+
+ formatViewCountLabel(total) {
+ const value = Number.isFinite(total) ? Number(total) : 0;
+ const label = value === 1 ? "view" : "views";
+ return `${formatViewCount(value)} ${label}`;
+ }
+
+ ensureViewCountSubscription(pointerInfo) {
+ if (!pointerInfo || !pointerInfo.key || !pointerInfo.pointer) {
+ return null;
+ }
+
+ const existing = this.viewCountSubscriptions.get(pointerInfo.key);
+ if (existing) {
+ return existing;
+ }
+
+ const entry = {
+ pointer: pointerInfo.pointer,
+ key: pointerInfo.key,
+ token: null,
+ elements: new Set(),
+ lastTotal: null,
+ lastStatus: "idle",
+ lastText: "– views",
+ };
+
+ try {
+ const token = subscribeToVideoViewCount(
+ pointerInfo.pointer,
+ ({ total, status }) => {
+ let text;
+ if (Number.isFinite(total)) {
+ const numeric = Number(total);
+ entry.lastTotal = numeric;
+ text = this.formatViewCountLabel(numeric);
+ } else if (status === "hydrating") {
+ text = "Loading views…";
+ } else {
+ text = "– views";
+ }
+
+ entry.lastStatus = status;
+ entry.lastText = text;
+
+ for (const el of Array.from(entry.elements)) {
+ if (!el || !el.isConnected) {
+ entry.elements.delete(el);
+ continue;
+ }
+ el.textContent = text;
+ }
+ }
+ );
+ entry.token = token;
+ this.viewCountSubscriptions.set(pointerInfo.key, entry);
+ return entry;
+ } catch (error) {
+ console.warn("[viewCount] Failed to subscribe to view counter:", error);
+ return null;
+ }
+ }
+
+ registerVideoViewCountElement(cardEl, pointerInfo) {
+ if (!cardEl || !pointerInfo) {
+ return;
+ }
+
+ const viewCountEl = cardEl.querySelector("[data-view-count]");
+ if (!viewCountEl) {
+ return;
+ }
+
+ if (!viewCountEl.textContent || !viewCountEl.textContent.trim()) {
+ viewCountEl.textContent = "– views";
+ }
+
+ const entry = this.ensureViewCountSubscription(pointerInfo);
+ if (!entry) {
+ return;
+ }
+
+ entry.elements.add(viewCountEl);
+ if (typeof pointerInfo.key === "string") {
+ viewCountEl.dataset.viewPointer = pointerInfo.key;
+ }
+ viewCountEl.textContent = entry.lastText;
+ }
+
+ pruneDetachedViewCountElements() {
+ for (const entry of this.viewCountSubscriptions.values()) {
+ if (!entry || !(entry.elements instanceof Set)) {
+ continue;
+ }
+ for (const el of Array.from(entry.elements)) {
+ if (!el || !el.isConnected) {
+ entry.elements.delete(el);
+ }
+ }
+ }
+ }
+
+ teardownAllViewCountSubscriptions() {
+ const keys = Array.from(this.viewCountSubscriptions.keys());
+ keys.forEach((key) => {
+ const entry = this.viewCountSubscriptions.get(key);
+ if (entry && entry.token && entry.pointer) {
+ try {
+ unsubscribeFromVideoViewCount(entry.pointer, entry.token);
+ } catch (error) {
+ console.warn(
+ `[viewCount] Failed to unsubscribe from pointer ${key}:`,
+ error
+ );
+ }
+ }
+ this.viewCountSubscriptions.delete(key);
+ });
+ }
+
+ teardownModalViewCountSubscription() {
+ if (typeof this.modalViewCountUnsub === "function") {
+ try {
+ this.modalViewCountUnsub();
+ } catch (error) {
+ console.warn("[viewCount] Failed to tear down modal subscription:", error);
+ }
+ }
+ this.modalViewCountUnsub = null;
+ if (this.videoViewCountEl) {
+ this.videoViewCountEl.textContent = "– views";
+ if (this.videoViewCountEl.dataset?.viewPointer) {
+ delete this.videoViewCountEl.dataset.viewPointer;
+ }
+ }
+ }
+
+ subscribeModalViewCount(pointer, pointerKey) {
+ if (!this.videoViewCountEl) {
+ return;
+ }
+
+ this.teardownModalViewCountSubscription();
+
+ if (!pointer || !pointerKey) {
+ return;
+ }
+
+ this.videoViewCountEl.textContent = "Loading views…";
+ try {
+ const token = subscribeToVideoViewCount(pointer, ({ total, status }) => {
+ if (!this.videoViewCountEl) {
+ return;
+ }
+
+ if (Number.isFinite(total)) {
+ const numeric = Number(total);
+ this.videoViewCountEl.textContent = this.formatViewCountLabel(numeric);
+ return;
+ }
+
+ if (status === "hydrating") {
+ this.videoViewCountEl.textContent = "Loading views…";
+ } else {
+ this.videoViewCountEl.textContent = "– views";
+ }
+ });
+
+ this.modalViewCountUnsub = () => {
+ try {
+ unsubscribeFromVideoViewCount(pointer, token);
+ } catch (error) {
+ console.warn(
+ "[viewCount] Failed to unsubscribe modal view counter:",
+ error
+ );
+ } finally {
+ this.modalViewCountUnsub = null;
+ }
+ };
+ this.videoViewCountEl.dataset.viewPointer = pointerKey;
+ } catch (error) {
+ console.warn("[viewCount] Failed to subscribe modal view counter:", error);
+ this.videoViewCountEl.textContent = "– views";
+ }
+ }
+
populateEditVideoForm(video) {
if (!video || !this.editVideoModal) {
return;
@@ -6368,11 +6611,18 @@ class bitvidApp {
this.cancelPendingViewLogging();
this.clearActiveIntervals();
this.cleanupUrlPlaybackWatchdog();
+ this.teardownModalViewCountSubscription();
if (!preserveObservers && this.mediaLoader) {
this.mediaLoader.disconnect();
}
+ if (!preserveObservers) {
+ this.teardownAllViewCountSubscriptions();
+ } else {
+ this.pruneDetachedViewCountElements();
+ }
+
if (!preserveSubscriptions && this.videoSubscription) {
try {
if (typeof this.videoSubscription.unsub === "function") {
@@ -6535,6 +6785,12 @@ class bitvidApp {
const viewOk = !!result?.view?.ok;
if (viewOk) {
this.loggedViewPointerKeys.add(thresholdPointerKey);
+ if (result?.view?.event) {
+ ingestLocalViewEvent({
+ event: result.view.event,
+ pointer: thresholdPointer,
+ });
+ }
} else if (isDevMode) {
console.warn(
"[playVideoWithFallback] View event rejected by relays:",
@@ -7084,6 +7340,7 @@ class bitvidApp {
// 1) Clear intervals, cleanup, etc. (unchanged)
this.cancelPendingViewLogging();
this.clearActiveIntervals();
+ this.teardownModalViewCountSubscription();
try {
await fetch("/webtorrent/cancel/", { mode: "no-cors" });
@@ -7519,6 +7776,40 @@ class bitvidApp {
const dedupedVideos = this.dedupeVideosByRoot(videos);
+ const activePointerKeys = new Set();
+ dedupedVideos.forEach((video) => {
+ const pointerInfo = this.deriveVideoPointerInfo(video);
+ if (pointerInfo) {
+ activePointerKeys.add(pointerInfo.key);
+ }
+ });
+
+ const keysToRemove = [];
+ for (const [key, entry] of this.viewCountSubscriptions.entries()) {
+ if (!activePointerKeys.has(key)) {
+ keysToRemove.push(key);
+ continue;
+ }
+ if (entry && entry.elements instanceof Set) {
+ entry.elements.clear();
+ }
+ }
+
+ keysToRemove.forEach((key) => {
+ const entry = this.viewCountSubscriptions.get(key);
+ if (entry && entry.pointer && entry.token) {
+ try {
+ unsubscribeFromVideoViewCount(entry.pointer, entry.token);
+ } catch (error) {
+ console.warn(
+ `[viewCount] Failed to unsubscribe from stale pointer ${key}:`,
+ error
+ );
+ }
+ }
+ this.viewCountSubscriptions.delete(key);
+ });
+
if (this.loadedThumbnails && this.loadedThumbnails.size) {
const activeIds = new Set();
dedupedVideos.forEach((video) => {
@@ -7793,6 +8084,18 @@ class bitvidApp {
`alt=\"${this.escapeHTML(video.title)}\"`
);
+ const pointerInfo = this.deriveVideoPointerInfo(video);
+ const metadataPieces = [`
${timeAgo}`];
+ if (pointerInfo) {
+ metadataPieces.push(
+ '
•',
+ `
– views`
+ );
+ }
+ const metadataHtml = metadataPieces.join("");
+
const cardHtml = `
@@ -7839,8 +8142,7 @@ class bitvidApp {
Loading name...
- ${timeAgo}
-
+ ${metadataHtml}
@@ -7893,6 +8195,10 @@ class bitvidApp {
: "missing-source";
}
+ if (pointerInfo) {
+ this.registerVideoViewCountElement(cardEl, pointerInfo);
+ }
+
const thumbnailEl = cardEl.querySelector("[data-video-thumbnail]");
if (thumbnailEl) {
const markThumbnailAsLoaded = () => {
@@ -8152,6 +8458,7 @@ class bitvidApp {
});
this.refreshVideoDiscussionCounts(dedupedVideos);
+ this.pruneDetachedViewCountElements();
}
refreshVideoDiscussionCounts(videos = []) {
@@ -9999,6 +10306,11 @@ class bitvidApp {
this.currentVideo.pointerKey = this.currentVideoPointerKey;
}
+ this.subscribeModalViewCount(
+ this.currentVideoPointer,
+ this.currentVideoPointerKey
+ );
+
this.syncModalMoreMenuData();
this.currentMagnetUri = sanitizedMagnet || null;
@@ -10079,6 +10391,7 @@ class bitvidApp {
} = {}) {
this.currentVideoPointer = null;
this.currentVideoPointerKey = null;
+ this.subscribeModalViewCount(null, null);
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";