mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #194 from PR0M3TH3AN/codex/implement-view-counter-api-integration
Add live view counters to video list and modal
This commit is contained in:
3 files changed
+340
-3
No files matched your search
@@ -133,7 +133,10 @@
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-gray-400 mb-4"
|
||||
>
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<div class="flex items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<span id="videoViewCount" class="modal-view-count">– views</span>
|
||||
</div>
|
||||
<div id="modalStatus" class="text-gray-300">
|
||||
Initializing... Just give it a sec.
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = [`<span>${timeAgo}</span>`];
|
||||
if (pointerInfo) {
|
||||
metadataPieces.push(
|
||||
'<span class="mx-1 text-gray-600" aria-hidden="true">•</span>',
|
||||
`<span class="view-count-text" data-view-count data-view-pointer="${this.escapeHTML(
|
||||
pointerInfo.key
|
||||
)}">– views</span>`
|
||||
);
|
||||
}
|
||||
const metadataHtml = metadataPieces.join("");
|
||||
|
||||
const cardHtml = `
|
||||
<div class="video-card bg-gray-900 rounded-lg overflow-hidden shadow-lg hover:shadow-2xl transition-all duration-300 ${highlightClass} ${animationClass}">
|
||||
<!-- The clickable link to play video -->
|
||||
@@ -7839,8 +8142,7 @@ class bitvidApp {
|
||||
Loading name...
|
||||
</p>
|
||||
<div class="flex items-center text-xs text-gray-500 mt-1">
|
||||
<span>${timeAgo}</span>
|
||||
<!-- We removed the 'Channel' button here -->
|
||||
${metadataHtml}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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() : "";
|
||||
|
||||
Reference in new issue
Block a user