diff --git a/js/channelProfile.js b/js/channelProfile.js
index 2bbcd26c..3eef1de3 100644
--- a/js/channelProfile.js
+++ b/js/channelProfile.js
@@ -8,8 +8,9 @@ import { subscriptions } from "./subscriptions.js"; // <-- NEW import
import { attachHealthBadges } from "./gridHealth.js";
import { attachUrlHealthBadges } from "./urlHealthObserver.js";
import { accessControl } from "./accessControl.js";
-import { escapeHTML } from "./utils/domUtils.js";
import { getApplication } from "./applicationContext.js";
+import { escapeHTML } from "./utils/domUtils.js";
+import { VideoCard } from "./ui/components/VideoCard.js";
import {
calculateZapShares,
describeShareType,
@@ -1704,9 +1705,97 @@ async function loadUserVideos(pubkey) {
container.dataset.hasChannelVideos = "true";
const fragment = document.createDocumentFragment();
const allKnownEventsArray = Array.from(nostrClient.allEvents.values());
+ const loadedThumbnails =
+ app?.loadedThumbnails instanceof Map ? app.loadedThumbnails : null;
+ const unsupportedBtihMessage =
+ typeof app?.getUnsupportedBtihMessage === "function"
+ ? app.getUnsupportedBtihMessage()
+ : "This magnet link is missing a compatible BitTorrent v1 info hash.";
+
+ const extractPlaybackDetail = (trigger, video) => {
+ if (
+ app?.videoListView &&
+ typeof app.videoListView.extractPlaybackDetail === "function"
+ ) {
+ return app.videoListView.extractPlaybackDetail(trigger, video);
+ }
+
+ const element = trigger instanceof HTMLElement ? trigger : null;
+ const target =
+ element?.closest("[data-play-url],[data-play-magnet]") || element;
+
+ const rawUrlValue =
+ (target?.dataset && typeof target.dataset.playUrl === "string"
+ ? target.dataset.playUrl
+ : null) ?? target?.getAttribute?.("data-play-url") ?? "";
+ const rawMagnetValue =
+ (target?.dataset && typeof target.dataset.playMagnet === "string"
+ ? target.dataset.playMagnet
+ : null) ?? target?.getAttribute?.("data-play-magnet") ?? "";
+
+ let url = "";
+ if (rawUrlValue) {
+ try {
+ url = decodeURIComponent(rawUrlValue);
+ } catch (error) {
+ url = rawUrlValue;
+ }
+ }
+
+ const magnet = typeof rawMagnetValue === "string" ? rawMagnetValue : "";
+ const videoId =
+ target?.dataset?.videoId ||
+ target?.getAttribute?.("data-video-id") ||
+ video?.id ||
+ "";
+
+ return { videoId, url, magnet, video };
+ };
+
+ const playbackHandler =
+ typeof app?.videoListViewPlaybackHandler === "function"
+ ? app.videoListViewPlaybackHandler.bind(app)
+ : null;
+
+ const startPlayback = (detail) => {
+ if (playbackHandler) {
+ playbackHandler(detail);
+ return;
+ }
+
+ if (detail.videoId && typeof app?.playVideoByEventId === "function") {
+ Promise.resolve(
+ app.playVideoByEventId(detail.videoId, {
+ url: detail.url,
+ magnet: detail.magnet,
+ title: detail.video?.title,
+ description: detail.video?.description,
+ })
+ ).catch((error) => {
+ console.error("Failed to play channel video via event id:", error);
+ if (typeof app?.playVideoWithFallback === "function") {
+ app.playVideoWithFallback({
+ url: detail.url,
+ magnet: detail.magnet,
+ });
+ }
+ });
+ return;
+ }
+
+ if (typeof app?.playVideoWithFallback === "function") {
+ Promise.resolve(
+ app.playVideoWithFallback({ url: detail.url, magnet: detail.magnet })
+ ).catch((error) => {
+ console.error(
+ "Failed to start fallback playback for channel video:",
+ error
+ );
+ });
+ }
+ };
videos.forEach((video, index) => {
- // Decrypt if user owns a private video
if (
video.isPrivate &&
video.pubkey === nostrClient.pubkey &&
@@ -1716,304 +1805,118 @@ async function loadUserVideos(pubkey) {
video.alreadyDecrypted = true;
}
- // Ensure the global videos map is kept up to date so delegated handlers
- // have the freshest metadata for this event.
app?.videosMap?.set(video.id, video);
- // Check if user can edit
const canEdit = video.pubkey === app.pubkey;
let hasOlder = false;
if (canEdit && video.videoRootId) {
hasOlder = app.hasOlderVersion(video, allKnownEventsArray);
}
- const revertButton = hasOlder
- ? `
-
- `
- : "";
-
- let gearMenu = "";
- if (canEdit) {
- gearMenu = `
-
-
-
-
-
- ${revertButton}
-
-
-
-
- `;
+ const pointerInfo =
+ typeof app?.deriveVideoPointerInfo === "function"
+ ? app.deriveVideoPointerInfo(video)
+ : null;
+ if (pointerInfo && typeof app?.persistWatchHistoryMetadataForVideo === "function") {
+ app.persistWatchHistoryMetadataForVideo(video, pointerInfo);
}
- const moreMenu = `
-
-
-
-
-
-
-
-
-
-
-
- `;
+ const shareUrl =
+ typeof app?.buildShareUrlFromEventId === "function"
+ ? app.buildShareUrlFromEventId(video.id)
+ : "#";
+ const timeAgo =
+ typeof app?.formatTimeAgo === "function"
+ ? app.formatTimeAgo(video.created_at)
+ : new Date(video.created_at * 1000).toLocaleString();
- const cardControls = `
-
- ${moreMenu}${gearMenu}
-
- `;
-
- // Fallback thumbnail
- const fallbackThumb = "assets/jpg/video-thumbnail-fallback.jpg";
- const safeThumb = video.thumbnail || fallbackThumb;
- const safeTitle = escapeHTML(video.title);
-
- const cardEl = document.createElement("div");
- cardEl.classList.add(
- "video-card",
- "bg-gray-900",
- "rounded-lg",
- "overflow-hidden",
- "shadow-lg",
- "hover:shadow-2xl",
- "transition-all",
- "duration-300"
- );
-
- cardEl.dataset.ownerIsViewer = canEdit ? "true" : "false";
- if (typeof video.pubkey === "string" && video.pubkey) {
- cardEl.dataset.ownerPubkey = video.pubkey;
- } else if (cardEl.dataset.ownerPubkey) {
- delete cardEl.dataset.ownerPubkey;
- }
- if (typeof video.id === "string" && video.id) {
- cardEl.dataset.videoId = video.id;
- } else if (cardEl.dataset.videoId) {
- delete cardEl.dataset.videoId;
- }
-
- const rawMagnet =
- typeof video.magnet === "string" ? video.magnet : "";
- const trimmedMagnet = rawMagnet ? rawMagnet.trim() : "";
- const legacyInfoHash =
- typeof video.infoHash === "string" ? video.infoHash.trim() : "";
- const playbackUrl =
- typeof video.url === "string" ? video.url : "";
- const trimmedUrl = playbackUrl ? playbackUrl.trim() : "";
- const playbackMagnet = trimmedMagnet || legacyInfoHash || "";
- const magnetProvided = playbackMagnet.length > 0;
- const magnetSupported = app.isMagnetUriSupported(playbackMagnet);
- const showUnsupportedTorrentBadge =
- !trimmedUrl && magnetProvided && !magnetSupported;
- const urlBadgeHtml = trimmedUrl
- ? app.getUrlHealthPlaceholderMarkup({ includeMargin: false })
- : "";
- const torrentHealthBadgeHtml =
- magnetSupported && magnetProvided
- ? app.getTorrentHealthBadgeMarkup({ includeMargin: false })
- : "";
- const connectionBadgesHtml =
- urlBadgeHtml || torrentHealthBadgeHtml
- ? `
-
- ${urlBadgeHtml}${torrentHealthBadgeHtml}
-
- `
- : "";
-
- cardEl.innerHTML = `
-
-
-

-
-
-
-
-
-
- ${safeTitle}
-
-
- ${new Date(video.created_at * 1000).toLocaleString()}
-
-
- ${cardControls}
-
- ${connectionBadgesHtml}
-
- `;
-
- if (showUnsupportedTorrentBadge) {
- cardEl.dataset.torrentSupported = "false";
- } else if (magnetProvided && magnetSupported) {
- cardEl.dataset.torrentSupported = "true";
- } else if (cardEl.dataset.torrentSupported) {
- delete cardEl.dataset.torrentSupported;
- }
-
- if (trimmedUrl) {
- cardEl.dataset.urlHealthState = "checking";
- if (cardEl.dataset.urlHealthReason) {
- delete cardEl.dataset.urlHealthReason;
- }
- cardEl.dataset.urlHealthEventId = video.id || "";
- cardEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
- } else {
- cardEl.dataset.urlHealthState = "offline";
- cardEl.dataset.urlHealthReason = "missing-source";
- if (cardEl.dataset.urlHealthEventId) {
- delete cardEl.dataset.urlHealthEventId;
- }
- if (cardEl.dataset.urlHealthUrl) {
- delete cardEl.dataset.urlHealthUrl;
- }
- }
- if (magnetProvided && magnetSupported) {
- cardEl.dataset.streamHealthState = "checking";
- if (cardEl.dataset.streamHealthReason) {
- delete cardEl.dataset.streamHealthReason;
- }
- } else {
- cardEl.dataset.streamHealthState = "unhealthy";
- cardEl.dataset.streamHealthReason = magnetProvided
- ? "unsupported"
- : "missing-source";
- }
-
- if (magnetProvided) {
- cardEl.dataset.magnet = playbackMagnet;
- } else if (cardEl.dataset.magnet) {
- delete cardEl.dataset.magnet;
- }
-
- if (trimmedUrl) {
- cardEl.dataset.playUrl = encodeURIComponent(trimmedUrl);
- } else if (cardEl.dataset.playUrl) {
- delete cardEl.dataset.playUrl;
- }
-
- cardEl.dataset.playMagnet = playbackMagnet || "";
-
- // Leave the data-play-* attributes empty in the template markup so the raw
- // URL/magnet strings can be assigned after parsing without HTML entity
- // escaping, keeping this renderer consistent with app.js. The stored URL is
- // encoded so it stays intact within data-* attributes, and the click
- // handler decodes it while leaving magnets untouched until
- // safeDecodeMagnet() runs.
- const interactiveEls = cardEl.querySelectorAll("[data-video-id]");
- interactiveEls.forEach((el) => {
- if (!el.dataset) return;
-
- if (trimmedUrl) {
- el.dataset.playUrl = encodeURIComponent(trimmedUrl);
- } else {
- delete el.dataset.playUrl;
- }
-
- el.dataset.playMagnet = playbackMagnet || "";
- if (magnetProvided) {
- el.dataset.torrentSupported = magnetSupported ? "true" : "false";
- } else if (el.dataset.torrentSupported) {
- delete el.dataset.torrentSupported;
- }
+ const videoCard = new VideoCard({
+ document,
+ video,
+ index,
+ shareUrl,
+ pointerInfo,
+ timeAgo,
+ capabilities: {
+ canEdit,
+ canDelete: canEdit,
+ canRevert: hasOlder,
+ canManageBlacklist:
+ typeof app?.canCurrentUserManageBlacklist === "function"
+ ? app.canCurrentUserManageBlacklist()
+ : false,
+ },
+ helpers: {
+ escapeHtml: (value) => escapeHTML(value),
+ isMagnetSupported: (magnet) => app.isMagnetUriSupported(magnet),
+ toLocaleString: (value) =>
+ typeof value === "number" ? value.toLocaleString() : value,
+ },
+ assets: {
+ fallbackThumbnailSrc: "assets/jpg/video-thumbnail-fallback.jpg",
+ unsupportedBtihMessage,
+ },
+ state: { loadedThumbnails },
+ ensureGlobalMoreMenuHandlers: () =>
+ app?.ensureGlobalMoreMenuHandlers?.(),
+ onRequestCloseAllMenus: () => app?.closeAllMoreMenus?.(),
+ formatters: {
+ formatTimeAgo: (ts) =>
+ typeof app?.formatTimeAgo === "function"
+ ? app.formatTimeAgo(ts)
+ : new Date(ts * 1000).toLocaleString(),
+ },
});
- const badgeEl = cardEl.querySelector("[data-url-health-state]");
- if (badgeEl) {
- if (trimmedUrl) {
- badgeEl.dataset.urlHealthEventId = video.id || "";
- badgeEl.dataset.urlHealthUrl = encodeURIComponent(trimmedUrl);
- } else {
- if (badgeEl.dataset.urlHealthEventId) {
- delete badgeEl.dataset.urlHealthEventId;
- }
- if (badgeEl.dataset.urlHealthUrl) {
- delete badgeEl.dataset.urlHealthUrl;
- }
- }
- }
+ videoCard.onPlay = ({ event: domEvent, video: cardVideo }) => {
+ const trigger = domEvent?.currentTarget || domEvent?.target;
+ const detail = extractPlaybackDetail(trigger, cardVideo || video);
+ detail.video = detail.video || video;
+ startPlayback(detail);
+ };
- fragment.appendChild(cardEl);
+ videoCard.onEdit = ({ video: editVideo, index: editIndex }) => {
+ app?.handleEditVideo?.({
+ eventId: editVideo?.id || "",
+ index: Number.isFinite(editIndex) ? editIndex : null,
+ });
+ };
+
+ videoCard.onRevert = ({ video: revertVideo, index: revertIndex }) => {
+ app?.handleRevertVideo?.({
+ eventId: revertVideo?.id || "",
+ index: Number.isFinite(revertIndex) ? revertIndex : null,
+ });
+ };
+
+ videoCard.onDelete = ({ video: deleteVideo, index: deleteIndex }) => {
+ app?.handleFullDeleteVideo?.({
+ eventId: deleteVideo?.id || "",
+ index: Number.isFinite(deleteIndex) ? deleteIndex : null,
+ });
+ };
+
+ videoCard.onMoreAction = ({ dataset = {} }) => {
+ const action = dataset.action || "";
+ const detail = {
+ ...dataset,
+ eventId: dataset.eventId || video.id || "",
+ context: dataset.context || "channel-grid",
+ };
+ app?.handleMoreMenuAction?.(action || "copy-link", detail);
+ };
+
+ videoCard.onAuthorNavigate = ({ pubkey }) => {
+ const targetPubkey = pubkey || video.pubkey || "";
+ if (targetPubkey && typeof app?.goToProfile === "function") {
+ app.goToProfile(targetPubkey);
+ }
+ };
+
+ const cardEl = videoCard.getRoot();
+ if (cardEl) {
+ fragment.appendChild(cardEl);
+ }
});
container.appendChild(fragment);
@@ -2026,144 +1929,11 @@ async function loadUserVideos(pubkey) {
app?.mountVideoListView?.();
- // Lazy-load images
const lazyEls = container.querySelectorAll("[data-lazy]");
lazyEls.forEach((el) => app?.mediaLoader?.observe?.(el));
app?.attachMoreMenuHandlers?.(container);
- if (!container.dataset.playHandlerBound) {
- container.addEventListener("click", async (event) => {
- if (!event || !(event.target instanceof HTMLElement)) {
- return;
- }
-
- const trigger = event.target.closest("[data-play-url],[data-play-magnet]");
- if (!trigger || !(trigger instanceof HTMLElement)) {
- return;
- }
-
- const isPrimaryClick =
- typeof event.button !== "number" || event.button === 0;
- if (!isPrimaryClick || event.ctrlKey || event.metaKey) {
- return;
- }
-
- if (!container.contains(trigger)) {
- return;
- }
-
- event.preventDefault();
-
- const dataset = trigger.dataset || {};
- const videoId = dataset.videoId || trigger.getAttribute("data-video-id") || "";
- const rawUrl = dataset.playUrl || trigger.getAttribute("data-play-url") || "";
- let url = "";
- if (typeof rawUrl === "string" && rawUrl) {
- try {
- url = decodeURIComponent(rawUrl);
- } catch (error) {
- url = rawUrl;
- }
- }
-
- const magnetValue =
- dataset.playMagnet || trigger.getAttribute("data-play-magnet") || "";
- const magnet = typeof magnetValue === "string" ? magnetValue : "";
-
- const playWithUrlAndMagnet = () => {
- if (typeof app?.playVideoWithFallback === "function") {
- app.playVideoWithFallback({ url, magnet });
- }
- };
-
- if (videoId && typeof app?.playVideoByEventId === "function") {
- try {
- const result = await app.playVideoByEventId(videoId, {
- url,
- magnet,
- });
- if (!result || result.error) {
- playWithUrlAndMagnet();
- }
- } catch (error) {
- console.error(
- "Failed to play via event id from channel grid:",
- error
- );
- playWithUrlAndMagnet();
- }
- return;
- }
-
- playWithUrlAndMagnet();
- });
- container.dataset.playHandlerBound = "true";
- }
-
- // Gear menu toggles
- const gearButtons = container.querySelectorAll("[data-settings-dropdown]");
- gearButtons.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idx = btn.getAttribute("data-settings-dropdown");
- const dropdown = document.getElementById(`settingsDropdown-${idx}`);
- if (dropdown) {
- dropdown.classList.toggle("hidden");
- }
- });
- });
-
- // Edit handler
- const editBtns = container.querySelectorAll("[data-edit-index]");
- editBtns.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-edit-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dropdown) dropdown.classList.add("hidden");
- const eventId = btn.getAttribute("data-edit-event-id") || "";
- app?.handleEditVideo?.({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
-
- // Revert handler
- const revertBtns = container.querySelectorAll("[data-revert-index]");
- revertBtns.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-revert-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dropdown) dropdown.classList.add("hidden");
- const eventId = btn.getAttribute("data-revert-event-id") || "";
- app?.handleRevertVideo?.({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
-
- // Delete All handler
- const deleteAllBtns = container.querySelectorAll("[data-delete-all-index]");
- deleteAllBtns.forEach((btn) => {
- btn.addEventListener("click", (ev) => {
- ev.stopPropagation();
- const idxAttr = btn.getAttribute("data-delete-all-index");
- const idx = Number.parseInt(idxAttr, 10);
- const dropdown = document.getElementById(`settingsDropdown-${idxAttr}`);
- if (dropdown) dropdown.classList.add("hidden");
- const eventId = btn.getAttribute("data-delete-all-event-id") || "";
- app?.handleFullDeleteVideo?.({
- eventId,
- index: Number.isNaN(idx) ? null : idx,
- });
- });
- });
} catch (err) {
if (loadToken === currentVideoLoadToken && container) {
container.dataset.hasChannelVideos = "false";