Files
bitvid/js/ui/videoModalController.js
T
thePR0M3TH3ANandClaude Opus 4.8 793d609d6a fix(video-modal): wire the share popover's Copy URL / Magnet / CDN buttons
The share popover's menu items (Copy URL, Copy Magnet, Copy CDN Link, Share on
Nostr) only set `data-action` — like the ⋯ menu — but unlike the ⋯ menu (which
moreMenuController delegates) nothing wired their clicks, so they did nothing.
Only the bottom "forced-source" test links self-wire. Same dead-button class as
the old embed/creator-link bugs.

Add a delegated click handler on the share panel (bindShareMenuActions) that
routes each action to the event videoModalController already handles:
copy-magnet → video:copy-magnet, copy-cdn → video:copy-cdn, share-nostr →
video:share-nostr, and share (Copy URL) → a new video:copy-url (buildShareUrl
makes the plain `?v=<nevent>` deep link; controller copies it with a toast).
Forced-source links are skipped (they self-wire).

Tests: tests/video-modal-controllers.test.mjs (+5 routing scenarios, 24/24).
Lint + build clean.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-09 12:46:37 -04:00

420 lines
11 KiB
JavaScript

import { devLogger, userLogger } from "../utils/logger.js";
import { SHORT_TIMEOUT_MS } from "../constants.js";
export default class VideoModalController {
constructor({
getVideoModal,
callbacks: {
showError,
showSuccess,
showStatus,
getLastModalTrigger,
setLastModalTrigger,
getCurrentVideo,
getPlaySource,
getStreamHealthSnapshots,
getCachedUrlHealth,
handleCopyMagnet,
openShareNostrModal,
playVideoWithFallback,
attachMoreMenuHandlers,
} = {},
}) {
this.getVideoModal = getVideoModal;
this.showError = showError;
this.showSuccess = showSuccess;
this.showStatus = showStatus;
this.getLastModalTrigger = getLastModalTrigger;
this.setLastModalTrigger = setLastModalTrigger;
this.getCurrentVideo = getCurrentVideo;
this.getPlaySource = getPlaySource;
this.getStreamHealthSnapshots = getStreamHealthSnapshots;
this.getCachedUrlHealth = getCachedUrlHealth;
this.handleCopyMagnetCallback = handleCopyMagnet;
this.openShareNostrModalCallback = openShareNostrModal;
this.playVideoWithFallbackCallback = playVideoWithFallback;
this.attachMoreMenuHandlersCallback = attachMoreMenuHandlers;
this.videoModalReadyPromise = null;
}
get videoModal() {
return this.getVideoModal ? this.getVideoModal() : null;
}
log(message, ...args) {
devLogger.log("[VideoModalController]", message, ...args);
}
bindEvents() {
if (!this.videoModal) {
return;
}
const modalRoot =
typeof this.videoModal.getRoot === "function"
? this.videoModal.getRoot()
: null;
if (modalRoot && this.attachMoreMenuHandlersCallback) {
this.attachMoreMenuHandlersCallback(modalRoot);
}
if (typeof this.videoModal.addEventListener === "function") {
this.videoModal.addEventListener("video:share-nostr", (event) => {
this.handleShareNostr(event);
});
this.videoModal.addEventListener("video:copy-cdn", (event) => {
this.handleCopyCdn(event);
});
this.videoModal.addEventListener("video:copy-url", (event) => {
this.handleCopyUrl(event);
});
this.videoModal.addEventListener("video:copy-magnet", () => {
if (this.handleCopyMagnetCallback) {
this.handleCopyMagnetCallback();
}
});
this.videoModal.addEventListener("playback:switch-source", (event) => {
this.handleSourceSwitch(event);
});
}
}
handleShareNostr(event) {
if (this.openShareNostrModalCallback) {
this.openShareNostrModalCallback({
video: event?.detail?.video || null,
triggerElement: event?.detail?.trigger || null,
});
}
}
handleCopyCdn(event) {
const video =
event?.detail?.video ||
(this.getCurrentVideo ? this.getCurrentVideo() : null);
const url = video?.url || "";
if (!url) {
if (this.showError) {
this.showError("No CDN link available to copy.");
}
return;
}
navigator.clipboard
.writeText(url)
.then(() => {
if (this.showSuccess) {
this.showSuccess("CDN link copied to clipboard!");
}
})
.catch(() => {
if (this.showError) {
this.showError("Failed to copy CDN link.");
}
});
}
handleCopyUrl(event) {
const url = event?.detail?.url || "";
if (!url) {
if (this.showError) {
this.showError("Could not generate a share link.");
}
return;
}
navigator.clipboard
.writeText(url)
.then(() => {
if (this.showSuccess) {
this.showSuccess("Video link copied to clipboard!");
}
})
.catch(() => {
if (this.showError) {
this.showError("Failed to copy the link.");
}
});
}
handleSourceSwitch(event) {
const detail = event?.detail || {};
const { source } = detail;
if (!source) {
return;
}
const modalVideo = detail?.video || null;
const fallbackVideo = this.getCurrentVideo ? this.getCurrentVideo() : null;
const video = {
...(fallbackVideo || {}),
...(modalVideo || {}),
};
const urlCandidate =
typeof video.url === "string" ? video.url.trim() : "";
const magnetCandidate =
typeof video.magnet === "string" ? video.magnet.trim() : "";
if (!modalVideo && !fallbackVideo) {
devLogger.warn(
"[VideoModalController] Playback source switch missing video data.",
);
return;
}
const magnetAvailable = Boolean(magnetCandidate);
const streamHealthSnapshots = this.getStreamHealthSnapshots
? this.getStreamHealthSnapshots()
: null;
const cachedStreamHealth =
video?.id && streamHealthSnapshots instanceof Map
? streamHealthSnapshots.get(video.id)
: null;
const cachedPeers = Number.isFinite(cachedStreamHealth?.peers)
? cachedStreamHealth.peers
: null;
const hasActivePeers = cachedPeers === null ? null : cachedPeers > 0;
const cachedUrlHealth =
video?.id && urlCandidate && this.getCachedUrlHealth
? this.getCachedUrlHealth(video.id, urlCandidate)
: null;
const cdnUnavailable =
!urlCandidate ||
["offline", "timeout"].includes(cachedUrlHealth?.status);
if (source === "torrent" && !magnetAvailable) {
userLogger.warn(
"[VideoModalController] Unable to switch to torrent playback: missing magnet.",
);
if (this.showError) {
this.showError(
"Torrent playback is unavailable for this video. No magnet was provided.",
);
}
return;
}
if (source === "torrent" && hasActivePeers === false) {
userLogger.warn(
"[VideoModalController] Switching to torrent playback despite 0 active peers detected.",
);
if (this.showStatus) {
this.showStatus(
"Warning: No peers detected. Playback may fail or stall.",
{ autoHideMs: SHORT_TIMEOUT_MS },
);
}
// Proceed anyway
}
if (source === "url" && cdnUnavailable) {
userLogger.warn(
"[VideoModalController] Unable to switch to CDN playback: URL unavailable.",
);
if (this.showError) {
this.showError(
"CDN playback is unavailable right now, staying on the torrent stream.",
);
}
return;
}
const currentSource = this.getPlaySource ? this.getPlaySource() : null;
if (currentSource && source === currentSource) {
return;
}
if (this.playVideoWithFallbackCallback) {
this.playVideoWithFallbackCallback({
url: urlCandidate,
magnet: magnetCandidate,
forcedSource: source,
}).catch((error) => {
devLogger.warn(
"[VideoModalController] Failed to switch playback source:",
error,
);
});
}
}
/**
* Show the modal and set the "Please stand by" poster on the video.
*/
async showModalWithPoster(video = null, options = {}) {
if (!this.videoModal) {
return null;
}
if (Object.prototype.hasOwnProperty.call(options || {}, "trigger")) {
if (this.setLastModalTrigger) {
this.setLastModalTrigger(options.trigger);
}
}
const targetVideo =
video || (this.getCurrentVideo ? this.getCurrentVideo() : null);
if (!targetVideo) {
this.log("Skipping video modal open; no target video is available.");
return null;
}
try {
const { root } = await this.ensureVideoModalReady({
ensureVideoElement: true,
});
if (!this.videoModal) {
return root || null;
}
const trigger = this.getLastModalTrigger
? this.getLastModalTrigger()
: null;
this.videoModal.open(targetVideo, {
triggerElement: trigger,
});
this.applyModalLoadingPoster();
return (
root ||
(typeof this.videoModal.getRoot === "function"
? this.videoModal.getRoot()
: null)
);
} catch (error) {
devLogger.error(
"[VideoModalController] Failed to open the video modal before playback:",
error,
);
if (this.showError) {
this.showError("Could not open the video player. Please try again.");
}
return null;
}
}
applyModalLoadingPoster() {
if (!this.videoModal) {
return;
}
this.videoModal.applyLoadingPoster();
}
forceRemoveModalPoster(reason = "manual-clear") {
if (!this.videoModal) {
return false;
}
return this.videoModal.forceRemovePoster(reason);
}
async ensureVideoModalReady({ ensureVideoElement = false } = {}) {
if (!this.videoModal) {
throw new Error("Video modal instance is not available.");
}
const getRoot = () =>
typeof this.videoModal.getRoot === "function"
? this.videoModal.getRoot()
: null;
const getVideoElement = () =>
typeof this.videoModal.getVideoElement === "function"
? this.videoModal.getVideoElement()
: null;
const existingRoot = getRoot();
const existingVideoElement = getVideoElement();
const rootConnected = Boolean(existingRoot && existingRoot.isConnected);
const hasVideoElement = Boolean(existingVideoElement);
const videoConnected = Boolean(
existingVideoElement && existingVideoElement.isConnected,
);
const needsRehydrate =
!rootConnected || !hasVideoElement || !videoConnected;
if (!needsRehydrate) {
if (
existingVideoElement &&
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
const currentModalVideo =
typeof this.videoModal.getVideoElement === "function"
? this.videoModal.getVideoElement()
: null;
if (currentModalVideo !== existingVideoElement) {
this.videoModal.setVideoElement(existingVideoElement);
}
}
return {
root: existingRoot,
videoElement: existingVideoElement,
};
}
if (!videoConnected) {
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
this.videoModal.setVideoElement(null);
}
}
if (!this.videoModalReadyPromise) {
if (typeof this.videoModal.load !== "function") {
throw new Error("Video modal does not expose a load() method.");
}
this.videoModalReadyPromise = Promise.resolve(this.videoModal.load());
}
try {
await this.videoModalReadyPromise;
} catch (error) {
this.videoModalReadyPromise = null;
throw error;
}
this.videoModalReadyPromise = null;
const readyRoot = getRoot();
const readyVideoElement = getVideoElement();
const readyVideoConnected = Boolean(
readyVideoElement && readyVideoElement.isConnected,
);
if (readyVideoConnected) {
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
this.videoModal.setVideoElement(readyVideoElement);
}
} else {
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
this.videoModal.setVideoElement(null);
}
}
if (ensureVideoElement && !readyVideoConnected) {
throw new Error("Video modal video element is missing after load().");
}
return {
root: readyRoot,
videoElement: readyVideoConnected ? readyVideoElement : null,
};
}
}