diff --git a/js/app.js b/js/app.js index 8788f662..da6bcf1c 100644 --- a/js/app.js +++ b/js/app.js @@ -238,29 +238,30 @@ class Application { deriveTorrentPlaybackConfig, isValidMagnetUri, urlFirstEnabled: URL_FIRST_ENABLED, - analyticsCallbacks: { - "session-start": (detail) => { - const urlProvided = detail?.urlProvided ? "true" : "false"; - const magnetProvided = detail?.magnetProvided ? "true" : "false"; - const magnetUsable = detail?.magnetUsable ? "true" : "false"; - this.log( - `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}` - ); - }, - fallback: (detail) => { - if (detail?.reason) { + analyticsCallbacks: { + "session-start": (detail) => { + const urlProvided = detail?.urlProvided ? "true" : "false"; + const magnetProvided = detail?.magnetProvided ? "true" : "false"; + const magnetUsable = detail?.magnetUsable ? "true" : "false"; this.log( - `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).` + `[playVideoWithFallback] Session start urlProvided=${urlProvided} magnetProvided=${magnetProvided} magnetUsable=${magnetUsable}` ); - } + }, + fallback: (detail) => { + if (detail?.reason) { + this.log( + `[playVideoWithFallback] Falling back to WebTorrent (${detail.reason}).` + ); + } + }, + error: (detail) => { + if (detail?.error) { + this.log("[playVideoWithFallback] Playback error observed", detail.error); + } + }, }, - error: (detail) => { - if (detail?.error) { - this.log("[playVideoWithFallback] Playback error observed", detail.error); - } - }, - }, }); + this.activePlaybackResultPromise = null; this.activePlaybackSession = null; this.authService = @@ -294,7 +295,6 @@ class Application { this.isWalletPaneBusy = false; this.profileModalCachedSelection = null; this.profileModalController = null; - this.boundProfileHistoryVisibility = null; this.profileSwitcherSelectionPubkey = null; this.currentUserNpub = null; @@ -639,7 +639,9 @@ class Application { this.videoListViewPlaybackHandler = ({ videoId, url, magnet }) => { if (videoId) { - Promise.resolve(this.playVideoByEventId(videoId)).catch((error) => { + Promise.resolve( + this.playVideoByEventId(videoId, { url, magnet }) + ).catch((error) => { console.error("[VideoListView] Failed to play by event id:", error); }); return; @@ -1285,6 +1287,11 @@ class Application { return pane; }, setActivePane: (pane) => { + if (pane === null) { + this.activeProfilePane = null; + return this.activeProfilePane; + } + const normalized = typeof pane === "string" && pane.trim() ? pane.trim().toLowerCase() @@ -7003,11 +7010,32 @@ class Application { * and falls back to WebTorrent when needed. */ async playVideoWithFallback({ url = "", magnet = "" } = {}) { - await this.waitForCleanup(); - this.cancelPendingViewLogging(); - const sanitizedUrl = typeof url === "string" ? url.trim() : ""; const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : ""; + const requestSignature = JSON.stringify({ + url: sanitizedUrl, + magnet: trimmedMagnet, + }); + + if ( + this.activePlaybackSession && + typeof this.activePlaybackSession.matchesRequestSignature === "function" && + this.activePlaybackSession.matchesRequestSignature(requestSignature) + ) { + this.log( + "[playVideoWithFallback] Duplicate playback request detected; reusing active session." + ); + if (this.activePlaybackResultPromise) { + return this.activePlaybackResultPromise; + } + if (typeof this.activePlaybackSession.getResult === "function") { + return this.activePlaybackSession.getResult(); + } + return { source: null }; + } + + await this.waitForCleanup(); + this.cancelPendingViewLogging(); let modalVideoEl = this.modalVideo; if (!modalVideoEl && this.videoModal) { @@ -7056,11 +7084,15 @@ class Application { if (refreshedModal) { this.modalVideo = refreshedModal; modalVideoEl = this.modalVideo; + this.applyModalLoadingPoster(); + } else { + this.applyModalLoadingPoster(); } const session = this.playbackService.createSession({ url: sanitizedUrl, magnet: trimmedMagnet, + requestSignature, videoElement: this.modalVideo, waitForCleanup: () => this.waitForCleanup(), cancelPendingViewLogging: () => this.cancelPendingViewLogging(), @@ -7076,6 +7108,7 @@ class Application { }); this.activePlaybackSession = session; + this.activePlaybackResultPromise = null; this.resetTorrentStats(); this.playSource = null; @@ -7144,6 +7177,7 @@ class Application { subscribe("finished", () => { if (this.activePlaybackSession === session) { this.activePlaybackSession = null; + this.activePlaybackResultPromise = null; } while (unsubscribers.length) { const off = unsubscribers.pop(); @@ -7160,7 +7194,9 @@ class Application { } }); - const result = await session.start(); + const startPromise = session.start(); + this.activePlaybackResultPromise = startPromise; + const result = await startPromise; if (!result || result.error) { return result; @@ -7170,12 +7206,30 @@ class Application { } - async playVideoByEventId(eventId) { + async playVideoByEventId(eventId, playbackHint = {}) { if (!eventId) { this.showError("No video identifier provided."); return; } + const fallbackUrl = + typeof playbackHint?.url === "string" ? playbackHint.url.trim() : ""; + const fallbackTitle = + typeof playbackHint?.title === "string" ? playbackHint.title : ""; + const fallbackDescription = + typeof playbackHint?.description === "string" + ? playbackHint.description + : ""; + const fallbackMagnetRaw = + typeof playbackHint?.magnet === "string" + ? playbackHint.magnet.trim() + : ""; + let fallbackMagnetCandidate = ""; + if (fallbackMagnetRaw) { + const decoded = safeDecodeMagnet(fallbackMagnetRaw); + fallbackMagnetCandidate = decoded || fallbackMagnetRaw; + } + this.currentVideoPointer = null; this.currentVideoPointerKey = null; @@ -7189,6 +7243,14 @@ class Application { video = await this.getOldEventById(eventId); } if (!video) { + if (fallbackUrl || fallbackMagnetCandidate) { + return this.playVideoWithoutEvent({ + url: fallbackUrl, + magnet: fallbackMagnetCandidate, + title: fallbackTitle || "Untitled", + description: fallbackDescription || "", + }); + } this.showError("Video not found or has been removed."); return; } @@ -7196,7 +7258,10 @@ class Application { try { await accessControl.ensureReady(); } catch (error) { - console.warn("Failed to ensure admin lists were loaded before playback:", error); + console.warn( + "Failed to ensure admin lists were loaded before playback:", + error + ); } const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey; if (!accessControl.canAccess(authorNpub)) { @@ -7219,15 +7284,34 @@ class Application { video.alreadyDecrypted = true; } - const trimmedUrl = typeof video.url === "string" ? video.url.trim() : ""; + let trimmedUrl = typeof video.url === "string" ? video.url.trim() : ""; + if (!trimmedUrl && fallbackUrl) { + trimmedUrl = fallbackUrl; + } const rawMagnet = typeof video.magnet === "string" ? video.magnet.trim() : ""; - const legacyInfoHash = + let legacyInfoHash = typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : ""; - const magnetCandidate = rawMagnet || legacyInfoHash; - const decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate); - const usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate; - const magnetSupported = isValidMagnetUri(usableMagnetCandidate); + const fallbackMagnetForCandidate = fallbackMagnetCandidate || ""; + if (!legacyInfoHash && fallbackMagnetForCandidate) { + const match = fallbackMagnetForCandidate.match(/xt=urn:btih:([0-9a-z]+)/i); + if (match && match[1]) { + legacyInfoHash = match[1].toLowerCase(); + } + } + + let magnetCandidate = rawMagnet || legacyInfoHash || ""; + let decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate); + let usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate; + let magnetSupported = isValidMagnetUri(usableMagnetCandidate); + + if (!magnetSupported && fallbackMagnetForCandidate) { + magnetCandidate = fallbackMagnetForCandidate; + decodedMagnetCandidate = safeDecodeMagnet(magnetCandidate); + usableMagnetCandidate = decodedMagnetCandidate || magnetCandidate; + magnetSupported = isValidMagnetUri(usableMagnetCandidate); + } + const sanitizedMagnet = magnetSupported ? usableMagnetCandidate : ""; trackVideoView({ @@ -7242,7 +7326,8 @@ class Application { ...video, url: trimmedUrl, magnet: sanitizedMagnet, - originalMagnet: magnetCandidate, + originalMagnet: + magnetCandidate || fallbackMagnetForCandidate || legacyInfoHash || "", torrentSupported: magnetSupported, legacyInfoHash: video.legacyInfoHash || legacyInfoHash, lightningAddress: null, @@ -7321,6 +7406,23 @@ class Application { this.setModalZapVisibility(false); this.resetModalZapState(); + + const magnetInput = + sanitizedMagnet || + decodedMagnetCandidate || + magnetCandidate || + fallbackMagnetForCandidate || + legacyInfoHash || + ""; + + this.showModalWithPoster(this.currentVideo); + this.applyModalLoadingPoster(); + + const playbackPromise = this.playVideoWithFallback({ + url: trimmedUrl, + magnet: magnetInput, + }); + let lightningAddress = ""; let creatorProfile = { name: "Unknown", @@ -7363,17 +7465,12 @@ class Application { }); } - const magnetInput = - sanitizedMagnet || - decodedMagnetCandidate || - magnetCandidate || - legacyInfoHash || - ""; + const playbackResult = + playbackPromise && typeof playbackPromise.then === "function" + ? await playbackPromise + : playbackPromise; - await this.playVideoWithFallback({ - url: trimmedUrl, - magnet: magnetInput, - }); + return playbackResult; } async playVideoWithoutEvent({ @@ -7453,7 +7550,7 @@ class Application { const cleaned = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`; window.history.replaceState({}, "", cleaned); - await this.playVideoWithFallback({ + return this.playVideoWithFallback({ url: sanitizedUrl, magnet: usableMagnet, }); diff --git a/js/channelProfile.js b/js/channelProfile.js index d20db4cf..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, @@ -118,6 +119,7 @@ let cachedZapSendBtn = null; let pendingZapRetry = null; let zapInFlight = false; let zapControlsOpen = false; +let currentVideoLoadToken = 0; function getChannelZapButton() { if (cachedZapButton && !document.body.contains(cachedZapButton)) { @@ -1584,13 +1586,32 @@ async function loadUserProfile(pubkey) { */ async function loadUserVideos(pubkey) { const app = getApp(); - try { - try { - await accessControl.ensureReady(); - } catch (error) { - console.warn("Failed to ensure admin lists were loaded before channel fetch:", error); - } + const container = document.getElementById("channelVideoList"); + const loadToken = ++currentVideoLoadToken; + const hadExistingContent = + !!container && container.querySelector("[data-video-id]"); + if (container) { + container.dataset.loading = "true"; + if (!hadExistingContent) { + container.innerHTML = ` +
+ Loading videos… +
+ `; + } + } + + const ensureAccessPromise = accessControl + .ensureReady() + .catch((error) => { + console.warn( + "Failed to ensure admin lists were loaded before channel fetch:", + error + ); + }); + + try { // 1) Build filter for videos from this pubkey const filter = { kinds: [30078], @@ -1638,6 +1659,17 @@ async function loadUserVideos(pubkey) { }); } + await ensureAccessPromise; + + if (loadToken !== currentVideoLoadToken) { + return; + } + + if (!container) { + console.warn("channelVideoList element not found in DOM."); + return; + } + // 3) Convert to "video" objects and keep everything (including tombstones) const convertedVideos = events .map((evt) => sharedConvertEventToVideo(evt)) @@ -1663,22 +1695,107 @@ async function loadUserVideos(pubkey) { videos.sort((a, b) => b.created_at - a.created_at); // 7) Render them - const container = document.getElementById("channelVideoList"); - if (!container) { - console.warn("channelVideoList element not found in DOM."); - return; - } container.innerHTML = ""; if (!videos.length) { + container.dataset.hasChannelVideos = "false"; container.innerHTML = `

No videos to display.

`; return; } + 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 && @@ -1688,291 +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 = ` -
- - -
- `; + 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; - } - - 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} -
-
-
-
-
-

- ${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; - } - - // 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); @@ -1985,143 +1929,24 @@ 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); - 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"; + container.innerHTML = ` +

Failed to load videos. Please try again.

+ `; + } console.error("Error loading user videos:", err); + } finally { + await ensureAccessPromise; + if (loadToken === currentVideoLoadToken && container) { + delete container.dataset.loading; + } } } diff --git a/js/historyView.js b/js/historyView.js index 4ea51288..003c83a4 100644 --- a/js/historyView.js +++ b/js/historyView.js @@ -1290,16 +1290,26 @@ export function createWatchHistoryRenderer(config = {}) { case "play": { event.preventDefault(); const videoId = trigger.dataset.videoId || pointerKeyAttr; + const rawUrlAttr = trigger.dataset.playUrl || ""; + const magnetAttr = trigger.dataset.playMagnet || ""; + let url = ""; + if (rawUrlAttr) { + try { + url = decodeURIComponent(rawUrlAttr); + } catch (error) { + url = rawUrlAttr; + } + } + const app = getAppInstance(); if (videoId && app?.playVideoByEventId) { - app.playVideoByEventId(videoId); + app.playVideoByEventId(videoId, { url, magnet: magnetAttr }); return; } - const urlAttr = trigger.dataset.playUrl || ""; - const magnetAttr = trigger.dataset.playMagnet || ""; + if (app?.playVideoWithFallback) { app.playVideoWithFallback({ - url: urlAttr ? decodeURIComponent(urlAttr) : "", + url, magnet: magnetAttr, }); } diff --git a/js/services/playbackService.js b/js/services/playbackService.js index 4551f5bf..b00cfa53 100644 --- a/js/services/playbackService.js +++ b/js/services/playbackService.js @@ -234,11 +234,20 @@ class PlaybackSession extends SimpleEventEmitter { this.options = options; this.watchdogCleanup = null; this.result = { source: null }; + this.finished = false; + this.startPromise = null; this.sanitizedUrl = typeof options.url === "string" ? options.url.trim() : ""; this.trimmedMagnet = typeof options.magnet === "string" ? options.magnet.trim() : ""; + this.requestSignature = + typeof options.requestSignature === "string" + ? options.requestSignature + : JSON.stringify({ + url: this.sanitizedUrl, + magnet: this.trimmedMagnet, + }); if (typeof service.deriveTorrentPlaybackConfig === "function") { this.playbackConfig = service.deriveTorrentPlaybackConfig({ @@ -273,6 +282,24 @@ class PlaybackSession extends SimpleEventEmitter { this.magnetProvided = !!this.playbackConfig.provided; } + isActive() { + return !this.finished; + } + + matchesRequestSignature(signature) { + if (!this.isActive()) { + return false; + } + if (typeof signature !== "string" || !signature) { + return false; + } + return signature === this.requestSignature; + } + + getResult() { + return this.result; + } + getPlaybackConfig() { return this.playbackConfig; } @@ -362,6 +389,14 @@ class PlaybackSession extends SimpleEventEmitter { } async start() { + if (this.startPromise) { + return this.startPromise; + } + this.startPromise = this.execute(); + return this.startPromise; + } + + async execute() { const { videoElement, waitForCleanup, @@ -376,6 +411,7 @@ class PlaybackSession extends SimpleEventEmitter { } = this.options; this.result = { source: null }; + this.finished = false; const detail = { urlProvided: !!this.sanitizedUrl, @@ -721,6 +757,7 @@ class PlaybackSession extends SimpleEventEmitter { return this.result; } finally { this.cleanupWatchdog(); + this.finished = true; if (this.result) { this.emit("finished", this.result); } diff --git a/js/subscriptions.js b/js/subscriptions.js index d8e191c4..63ba063f 100644 --- a/js/subscriptions.js +++ b/js/subscriptions.js @@ -581,7 +581,12 @@ class SubscriptionsManager { return; } if (detail.videoId) { - Promise.resolve(app?.playVideoByEventId?.(detail.videoId)).catch( + Promise.resolve( + app?.playVideoByEventId?.(detail.videoId, { + url: detail.url, + magnet: detail.magnet, + }) + ).catch( (error) => { console.error( "[SubscriptionsManager] Failed to play by event id:", diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index b0e9f26d..8c08f6a6 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -983,12 +983,35 @@ export class VideoCard { this.ensureGlobalMoreMenuHandlers(); } + const MouseEventCtor = this.window?.MouseEvent || globalThis.MouseEvent; + [this.anchorEl, this.titleEl].forEach((el) => { if (!el) return; el.addEventListener("click", (event) => { - if (this.callbacks.onPlay) { - this.callbacks.onPlay({ event, video: this.video, card: this }); + if (!this.callbacks.onPlay) { + return; } + + const isMouseEvent = + typeof MouseEventCtor !== "undefined" && event instanceof MouseEventCtor; + if (isMouseEvent) { + const isPrimaryClick = + typeof event.button !== "number" || event.button === 0; + const hasModifier = + event.ctrlKey || event.metaKey || event.shiftKey || event.altKey; + if (!isPrimaryClick || hasModifier) { + return; + } + } + + if (typeof event.preventDefault === "function") { + event.preventDefault(); + } + if (typeof event.stopPropagation === "function") { + event.stopPropagation(); + } + + this.callbacks.onPlay({ event, video: this.video, card: this }); }); }); diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index b4cc27f5..541fb23d 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -675,6 +675,7 @@ export class ProfileModalController { this.adminBlacklistInput = null; this.profileHistoryRenderer = null; + this.profileHistoryRendererConfig = null; this.boundProfileHistoryVisibility = null; this.boundKeydown = null; this.boundFocusIn = null; @@ -840,36 +841,70 @@ export class ProfileModalController { this.adminAddBlacklistButton = this.addBlacklistButton; this.adminBlacklistInput = this.blacklistInput; - if (!this.profileHistoryRenderer && this.createWatchHistoryRenderer) { - this.profileHistoryRenderer = this.createWatchHistoryRenderer({ - viewSelector: "#profilePaneHistory", - gridSelector: "#profileHistoryGrid", - loadingSelector: "#profileHistoryLoading", - statusSelector: "#profileHistoryStatus", - emptySelector: "#profileHistoryEmpty", - sentinelSelector: "#profileHistorySentinel", - scrollContainerSelector: "#profileHistoryScroll", - errorBannerSelector: "#profileHistoryError", - clearButtonSelector: "#profileHistoryClear", - republishButtonSelector: "#profileHistoryRepublish", - featureBannerSelector: "#profileHistoryFeatureBanner", - toastRegionSelector: "#profileHistoryToastRegion", - sessionWarningSelector: "#profileHistorySessionWarning", - metadataToggleSelector: "#profileHistoryMetadataToggle", - metadataThumbSelector: "#profileHistoryMetadataThumb", - metadataLabelSelector: "#profileHistoryMetadataLabel", - metadataDescriptionSelector: "#profileHistoryMetadataDescription", - emptyCopy: "You haven’t watched any videos yet.", - remove: (payload) => - this.callbacks.onHistoryReady({ - ...(typeof payload === "object" && payload ? payload : {}), - controller: this, - renderer: this.profileHistoryRenderer, - }), - }); + if (this.createWatchHistoryRenderer) { + this.ensureProfileHistoryRenderer(); } } + ensureProfileHistoryRenderer() { + if (this.profileHistoryRenderer) { + return this.profileHistoryRenderer; + } + + if (!this.createWatchHistoryRenderer) { + return null; + } + + const config = this.getProfileHistoryRendererConfig(); + + try { + this.profileHistoryRenderer = this.createWatchHistoryRenderer(config); + } catch (error) { + console.error( + "[profileModal] Failed to create watch history renderer:", + error, + ); + this.profileHistoryRenderer = null; + } + + return this.profileHistoryRenderer; + } + + getProfileHistoryRendererConfig() { + if (this.profileHistoryRendererConfig) { + return this.profileHistoryRendererConfig; + } + + this.profileHistoryRendererConfig = { + viewSelector: "#profilePaneHistory", + gridSelector: "#profileHistoryGrid", + loadingSelector: "#profileHistoryLoading", + statusSelector: "#profileHistoryStatus", + emptySelector: "#profileHistoryEmpty", + sentinelSelector: "#profileHistorySentinel", + scrollContainerSelector: "#profileHistoryScroll", + errorBannerSelector: "#profileHistoryError", + clearButtonSelector: "#profileHistoryClear", + republishButtonSelector: "#profileHistoryRepublish", + featureBannerSelector: "#profileHistoryFeatureBanner", + toastRegionSelector: "#profileHistoryToastRegion", + sessionWarningSelector: "#profileHistorySessionWarning", + metadataToggleSelector: "#profileHistoryMetadataToggle", + metadataThumbSelector: "#profileHistoryMetadataThumb", + metadataLabelSelector: "#profileHistoryMetadataLabel", + metadataDescriptionSelector: "#profileHistoryMetadataDescription", + emptyCopy: "You haven’t watched any videos yet.", + remove: (payload) => + this.callbacks.onHistoryReady({ + ...(typeof payload === "object" && payload ? payload : {}), + controller: this, + renderer: this.profileHistoryRenderer, + }), + }; + + return this.profileHistoryRendererConfig; + } + registerEventListeners() { if (this.closeButton instanceof HTMLElement) { this.closeButton.addEventListener("click", () => { @@ -1947,7 +1982,8 @@ export class ProfileModalController { } async populateProfileWatchHistory() { - if (!this.profileHistoryRenderer) { + const renderer = this.ensureProfileHistoryRenderer(); + if (!renderer) { return; } @@ -1960,8 +1996,8 @@ export class ProfileModalController { } try { - await this.profileHistoryRenderer.ensureInitialLoad({ actor: primaryActor }); - await this.profileHistoryRenderer.refresh({ + await renderer.ensureInitialLoad({ actor: primaryActor }); + await renderer.refresh({ actor: primaryActor, force: true, }); @@ -1984,9 +2020,9 @@ export class ProfileModalController { } if (document.visibilityState === "hidden") { - this.profileHistoryRenderer.pause(); + renderer.pause(); } else { - this.profileHistoryRenderer.resume(); + renderer.resume(); } } catch (error) { console.error( @@ -3701,6 +3737,36 @@ export class ProfileModalController { document.removeEventListener("focusin", this.boundFocusIn); } + if ( + this.boundProfileHistoryVisibility && + typeof document !== "undefined" && + typeof document.removeEventListener === "function" + ) { + document.removeEventListener( + "visibilitychange", + this.boundProfileHistoryVisibility, + ); + this.boundProfileHistoryVisibility = null; + } + + if (this.profileHistoryRenderer) { + try { + if (typeof this.profileHistoryRenderer.destroy === "function") { + this.profileHistoryRenderer.destroy(); + } + } catch (error) { + console.warn( + "[profileModal] Failed to reset watch history renderer on close:", + error, + ); + } + this.profileHistoryRenderer = null; + this.profileHistoryRendererConfig = null; + } + + this.setActivePane(null); + this.setWalletPaneBusy(false); + const previous = this.previouslyFocusedElement; this.previouslyFocusedElement = null;