mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
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]>
420 lines
11 KiB
JavaScript
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,
|
|
};
|
|
}
|
|
}
|