Files
bitvid/js/app/playbackCoordinator.js
T

1377 lines
45 KiB
JavaScript

// js/app/playbackCoordinator.js
/**
* URL-first + magnet fallback playback pipeline.
*
* All module-level dependencies are injected from the Application
* composition root rather than imported at module scope.
*
* Methods use `this` which is bound to the Application instance.
*/
/**
* Normalize a deep-link / caller "force this playback source" value to the
* canonical 'url' | 'torrent' the playback pipeline understands, or null when
* unset/unrecognized. Accepts a few friendly aliases (the in-modal toggle
* labels CDN/P2P, plus webtorrent/hosted) so shared links are forgiving.
* @param {unknown} value
* @returns {'url' | 'torrent' | null}
*/
export function normalizeForcedSource(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (["torrent", "p2p", "webtorrent", "magnet"].includes(normalized)) {
return "torrent";
}
if (["url", "cdn", "hosted", "http", "https"].includes(normalized)) {
return "url";
}
return null;
}
/**
* @param {object} deps - Injected dependencies.
* @returns {object} Methods to be bound to the Application instance.
*/
export function createPlaybackCoordinator(deps) {
const {
devLogger,
userLogger,
nostrClient,
torrentClient,
torrentMetadataService,
emit,
accessControl,
isValidMagnetUri,
safeDecodeMagnet,
extractBtihFromMagnet,
collectVideoTags,
resolveVideoPointer,
formatShortNpub,
formatAbsoluteDateWithOrdinalUtil,
getVideoRootIdentifier,
applyRootTimestampToVideosMap,
syncActiveVideoRootTimestamp,
fetchProfileMetadata,
ensureProfileMetadataSubscription,
dedupeToNewestByRoot,
dedupeVideos,
buildServiceWorkerFallbackStatus,
sanitizeProfileMediaUrl,
UNSUPPORTED_BTITH_MESSAGE,
BITVID_WEBSITE_URL,
FALLBACK_THUMBNAIL_SRC,
} = deps;
return {
checkUrlParams() {
const urlParams = new URLSearchParams(window.location.search);
const maybeNevent = urlParams.get("v");
if (!maybeNevent) return; // no link param
// Optional deep-link override that forces a playback source, mirroring the
// embed player's `playback` param. Lets you test a specific path
// consistently: `?v=<nevent>&playback=torrent` always streams via
// WebTorrent, `&playback=url` always uses the hosted CDN URL. If the
// requested source is missing for the video, playVideoByEventId surfaces
// a message instead of silently using the other source.
const forcedSource = normalizeForcedSource(urlParams.get("playback"));
try {
const decoded = window.NostrTools.nip19.decode(maybeNevent);
if (decoded.type === "nevent" && decoded.data.id) {
const eventId = decoded.data.id;
const relay =
Array.isArray(decoded.data.relays) && decoded.data.relays.length
? decoded.data.relays[0]
: null;
// 1) check local map
let localMatch = this.videosMap.get(eventId);
if (localMatch) {
this.playVideoByEventId(eventId, { relay, forcedSource });
} else {
// 2) fallback => getOldEventById
this.getOldEventById(eventId)
.then((video) => {
if (video) {
this.playVideoByEventId(eventId, { relay, forcedSource });
} else {
this.showError("No matching video found for that link.");
}
})
.catch((err) => {
devLogger.error("Error fetching older event by ID:", err);
this.showError("Could not load videos for the share link.");
});
}
}
} catch (err) {
devLogger.error("Error decoding nevent:", err);
this.showError("Invalid share link.");
}
},
shouldDeferModeratedPlayback(video) {
if (!video || typeof video !== "object") {
return false;
}
const moderation =
video.moderation && typeof video.moderation === "object"
? video.moderation
: null;
if (!moderation) {
return false;
}
if (moderation.viewerOverride?.showAnyway === true) {
return false;
}
const blurActive = moderation.blurThumbnail === true;
const hiddenActive = moderation.hidden === true;
return blurActive || hiddenActive;
},
resumePendingModeratedPlayback(video) {
const pending = this.pendingModeratedPlayback;
if (!pending) {
return;
}
const activeVideo = this.currentVideo || null;
const targetVideo = video && typeof video === "object" ? video : activeVideo;
if (!targetVideo) {
return;
}
const pendingId =
typeof pending.videoId === "string" && pending.videoId ? pending.videoId : "";
const targetId =
typeof targetVideo.id === "string" && targetVideo.id ? targetVideo.id : "";
const matchesId = pendingId && targetId && pendingId === targetId;
const matchesActive = !pendingId && !targetId && targetVideo === activeVideo;
if (!matchesId && !matchesActive) {
return;
}
this.pendingModeratedPlayback = null;
if (typeof this.playVideoWithFallback !== "function") {
return;
}
const playbackOptions = {
url: pending.url || "",
magnet: pending.magnet || "",
};
if (pending.triggerProvided) {
playbackOptions.trigger = Object.prototype.hasOwnProperty.call(pending, "trigger")
? pending.trigger
: this.lastModalTrigger || null;
}
const playbackPromise = this.playVideoWithFallback(playbackOptions);
if (playbackPromise && typeof playbackPromise.catch === "function") {
playbackPromise.catch((error) => {
devLogger.error(
"[Application] Failed to resume moderated playback:",
error,
);
});
}
},
buildShareUrlFromNevent(nevent) {
if (!nevent) {
return "";
}
const base = this.getShareUrlBase();
if (!base) {
return "";
}
return `${base}?v=${encodeURIComponent(nevent)}`;
},
buildShareUrlFromEventId(eventId) {
if (!eventId) {
return "";
}
try {
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
return this.buildShareUrlFromNevent(nevent);
} catch (err) {
devLogger.error("Error generating nevent for share URL:", err);
return "";
}
},
dedupeVideosByRoot(videos) {
if (!Array.isArray(videos) || videos.length === 0) {
return [];
}
// Root-dedupe AND collapse cross-ecosystem duplicates (prefer bitvid). This
// is the shared chokepoint for the feed stage + channel/search grids.
return typeof dedupeVideos === "function"
? dedupeVideos(videos)
: dedupeToNewestByRoot(videos);
},
autoplayModalVideo() {
if (this.currentVideo?.moderation?.blockAutoplay) {
this.log(
"[moderation] Skipping autoplay due to trusted reports or trusted mutes.",
);
return;
}
if (!this.modalVideo) return;
this.modalVideo.play().catch((err) => {
this.log("Autoplay failed:", err);
if (!this.modalVideo.muted) {
this.log("Falling back to muted autoplay.");
this.playbackService.muteForAutoplayFallback(this.modalVideo);
this.modalVideo.play().catch((err2) => {
this.log("Muted autoplay also failed:", err2);
});
}
});
},
startTorrentStatusMirrors(torrentInstance) {
if (!torrentInstance) {
return;
}
this.cacheTorrentStatusNodes();
const updateMirrorStatus = () => {
if (!document.body.contains(this.modalVideo)) {
this.stopTorrentStatusInterval();
this.removeTorrentStatusVisibilityHandlers();
return;
}
this.updateTorrentStatus(torrentInstance);
const { status, progress, peers, speed, downloaded } = this.torrentStatusNodes || {};
if (this.videoModal) {
if (status) {
this.videoModal.updateStatus(status.textContent);
}
if (progress) {
const doc = progress.ownerDocument;
const view = doc?.defaultView;
let widthValue = "";
if (view && typeof view.getComputedStyle === "function") {
const computed = view.getComputedStyle(progress);
widthValue =
computed?.getPropertyValue("--progress-width")?.trim() ||
computed?.getPropertyValue("width")?.trim() ||
"";
}
this.videoModal.updateProgress(widthValue);
}
if (peers) {
this.videoModal.updatePeers(peers.textContent);
}
if (speed) {
this.videoModal.updateSpeed(speed.textContent);
}
if (downloaded) {
this.videoModal.updateDownloaded(downloaded.textContent);
}
}
};
this.stopTorrentStatusInterval();
this.startTorrentStatusInterval(updateMirrorStatus);
this.addTorrentStatusVisibilityHandlers({
onPause: () => this.stopTorrentStatusInterval(),
onResume: () => this.startTorrentStatusInterval(updateMirrorStatus),
onClose: () => this.stopTorrentStatusInterval(),
});
},
startTorrentStatusInterval(callback) {
if (this.torrentStatusIntervalId) {
return;
}
if (document.visibilityState === "hidden") {
return;
}
const intervalId = setInterval(callback, 3000);
this.torrentStatusIntervalId = intervalId;
this.activeIntervals.push(intervalId);
},
stopTorrentStatusInterval() {
if (!this.torrentStatusIntervalId) {
return;
}
clearInterval(this.torrentStatusIntervalId);
this.removeActiveInterval(this.torrentStatusIntervalId);
this.torrentStatusIntervalId = null;
},
async probeUrlWithVideoElement(url, timeoutMs) {
return this.urlHealthController.probeUrlWithVideoElement(url, timeoutMs);
},
async probeUrl(url, options = {}) {
const trimmed = typeof url === "string" ? url.trim() : "";
if (!trimmed) {
return this.urlHealthController.probeUrl(url, options);
}
const existing = this.urlProbePromises.get(trimmed);
if (existing) {
return existing;
}
const probePromise = (async () => {
try {
return await this.urlHealthController.probeUrl(trimmed, options);
} finally {
this.urlProbePromises.delete(trimmed);
}
})();
this.urlProbePromises.set(trimmed, probePromise);
return probePromise;
},
async playHttp(videoEl, url) {
const target = videoEl || this.modalVideo;
if (!target) {
return false;
}
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
if (!sanitizedUrl) {
return false;
}
target.src = sanitizedUrl;
try {
await target.play();
return true;
} catch (err) {
devLogger.warn("[playHttp] Direct URL playback failed:", err);
return false;
}
},
async playViaWebTorrent(
magnet,
{ fallbackMagnet = "", urlList = [], hostedUrlWebSeed = "" } = {}
) {
const sanitizedUrlList = Array.isArray(urlList)
? urlList
.map((entry) =>
typeof entry === "string" ? entry.trim() : ""
)
.filter((entry) => /^https?:\/\//i.test(entry))
: [];
const sanitizedHostedWebSeed =
typeof hostedUrlWebSeed === "string" &&
/^https?:\/\//i.test(hostedUrlWebSeed.trim())
? hostedUrlWebSeed.trim()
: "";
// Blossom videos ship a webseed-only magnet (no xs=). When the piece-map
// lookup is wired (flag on) and the magnet lacks xs=, fetch the verified
// .torrent from the video's infohash-keyed companion event so WebTorrent has
// metadata to bootstrap from. Best-effort + verified upstream: null ⇒ the
// bare magnet is used (may still work via peers, else the URL fallback runs).
let companionTorrentBytes = null;
if (
torrentMetadataService &&
typeof extractBtihFromMagnet === "function" &&
typeof magnet === "string" &&
!/[?&]xs=/.test(magnet)
) {
const infoHash = extractBtihFromMagnet(magnet);
const author =
this.currentVideo && typeof this.currentVideo.pubkey === "string"
? this.currentVideo.pubkey
: "";
if (infoHash && author) {
try {
const relays =
Array.isArray(nostrClient?.readRelays) &&
nostrClient.readRelays.length
? nostrClient.readRelays
: nostrClient?.relays || [];
const resolved = await torrentMetadataService.fetch({
infoHash,
author,
// Route relay reads through the subscription manager (one-shot,
// deduped, timeout-bounded) — never pool.list directly.
queryEvents: (filter) =>
nostrClient
.getSubscriptionManager()
.list({ filters: [filter], relays }),
});
if (resolved?.torrentBytes) {
companionTorrentBytes = resolved.torrentBytes;
}
} catch (error) {
devLogger?.warn?.(
"[playback] companion torrent-metadata lookup failed:",
error
);
}
}
}
const attemptStream = async (candidate) => {
const trimmedCandidate =
typeof candidate === "string" ? candidate.trim() : "";
if (!trimmedCandidate) {
throw new Error("No magnet URI provided for torrent playback.");
}
if (!isValidMagnetUri(trimmedCandidate)) {
if (this.videoModal) {
this.videoModal.updateStatus(UNSUPPORTED_BTITH_MESSAGE);
}
throw new Error(UNSUPPORTED_BTITH_MESSAGE);
}
if (!this.modalVideo) {
throw new Error(
"No modal video element available for torrent playback."
);
}
const timestamp = Date.now().toString();
const [magnetPrefix, magnetQuery = ""] = trimmedCandidate.split("?", 2);
let normalizedMagnet = magnetPrefix;
let queryParts = magnetQuery
.split("&")
.map((part) => part.trim())
.filter((part) => part && !/^ts=\d+$/.test(part));
if (queryParts.length) {
normalizedMagnet = `${magnetPrefix}?${queryParts.join("&")}`;
}
const separator = normalizedMagnet.includes("?") ? "&" : "?";
const cacheBustedMagnet = `${normalizedMagnet}${separator}ts=${timestamp}`;
await torrentClient.cleanup();
this.resetTorrentStats();
if (this.videoModal) {
this.videoModal.updateStatus("Streaming via WebTorrent");
this.videoModal.setTorrentStatsVisibility?.(true);
}
const torrentInstance = await torrentClient.streamVideo(
cacheBustedMagnet,
this.modalVideo,
{
urlList: sanitizedUrlList,
hostedUrlWebSeed: sanitizedHostedWebSeed,
// Verified piece-map from the companion event (Blossom videos); when
// present, streamVideo adds this buffer instead of the bare magnet.
torrentFileBytes: companionTorrentBytes,
hooks: {
// Surface a mid-stream stall instead of silently freezing on a
// buffering frame: tell the user whether the swarm is just slow
// or has no seeders at all.
onStall: ({ peers } = {}) => {
if (!this.videoModal) {
return;
}
this.videoModal.updateStatus(
peers > 0
? "Buffering — the swarm is slow right now."
: "Waiting for peers — this video may have no active seeders."
);
},
// A torrent error after playback started can't reject the (already
// settled) start promise, so report it here.
onPlaybackError: (error) => {
this.log(
"[playViaWebTorrent] Torrent error during playback:",
error
);
if (this.videoModal) {
this.videoModal.updateStatus(
"WebTorrent playback hit an error — the swarm may have dropped."
);
}
},
},
}
);
if (torrentClient.isServiceWorkerUnavailable()) {
const swError = torrentClient.getServiceWorkerInitError();
const statusMessage = buildServiceWorkerFallbackStatus(swError);
this.log(
"[playViaWebTorrent] Service worker unavailable; streaming directly via WebTorrent.",
swError
);
if (swError) {
userLogger.warn(
"[playViaWebTorrent] Service worker unavailable; direct streaming engaged.",
swError
);
}
if (this.videoModal) {
this.videoModal.updateStatus(statusMessage);
}
}
if (torrentInstance && torrentInstance.ready) {
// Some browsers delay `playing` events for MediaSource-backed torrents.
// Clearing the poster here prevents the historic "GIF stuck over the
// video" regression when WebTorrent is already feeding data.
this.forceRemoveModalPoster("webtorrent-ready");
}
this.startTorrentStatusMirrors(torrentInstance);
return torrentInstance;
};
const primaryTrimmed =
typeof magnet === "string" ? magnet.trim() : "";
const fallbackTrimmed =
typeof fallbackMagnet === "string" ? fallbackMagnet.trim() : "";
const hasFallback =
!!fallbackTrimmed && fallbackTrimmed !== primaryTrimmed;
try {
return await attemptStream(primaryTrimmed);
} catch (primaryError) {
if (!hasFallback) {
throw primaryError;
}
this.log(
`[playViaWebTorrent] Normalized magnet failed: ${primaryError.message}`
);
this.log(
"[playViaWebTorrent] Primary magnet failed, retrying original string."
);
try {
return await attemptStream(fallbackTrimmed);
} catch (fallbackError) {
throw fallbackError;
}
}
},
/**
* Unified playback helper that prefers HTTP URL sources
* and falls back to WebTorrent when needed.
*/
async playVideoWithFallback(options = {}) {
const playbackStrategyService = this.playbackStrategyService;
if (!playbackStrategyService) {
this.showError("Playback strategy service is not available.");
return { source: null, error: new Error("Service missing") };
}
return playbackStrategyService.play(options, {
getModalVideo: () => this.modalVideo,
setModalVideo: (videoElement) => {
this.modalVideo = videoElement;
},
getVideoModalElement: () =>
this.videoModal && typeof this.videoModal.getVideoElement === "function"
? this.videoModal.getVideoElement()
: null,
setVideoModalElement: (videoElement) => {
if (
this.videoModal &&
typeof this.videoModal.setVideoElement === "function"
) {
this.videoModal.setVideoElement(videoElement);
}
},
cleanupVideoModalPoster: () => {
if (
this.videoModal &&
typeof this.videoModal.clearPosterCleanup === "function"
) {
this.videoModal.clearPosterCleanup();
}
},
ensureVideoModalReady: (opts) => this.ensureVideoModalReady(opts),
teardownVideoElement: (videoEl, opts) =>
this.teardownVideoElement(videoEl, opts),
applyModalLoadingPoster: () => this.applyModalLoadingPoster(),
waitForCleanup: () => this.waitForCleanup(),
cancelPendingViewLogging: () => this.cancelPendingViewLogging(),
clearActiveIntervals: () => this.clearActiveIntervals(),
showModalWithPoster: () => this.showModalWithPoster(),
probeUrl: (candidateUrl) => this.probeUrl(candidateUrl),
playViaWebTorrent: (magnetUri, opts) =>
this.playViaWebTorrent(magnetUri, opts),
autoplay: () => this.autoplayModalVideo(),
unsupportedBtihMessage: UNSUPPORTED_BTITH_MESSAGE,
showError: (msg) => this.showError(msg),
setLastModalTrigger: (trigger) => this.setLastModalTrigger(trigger),
resetTorrentStats: () => this.resetTorrentStats(),
setPlaySource: (source) => {
this.playSource = source;
},
setActivePlaybackSession: (session) => {
this.activePlaybackSession = session;
},
setActivePlaybackResultPromise: (promise) => {
this.activePlaybackResultPromise = promise;
},
updateCurrentVideoMetadata: (metadata) => {
if (this.currentVideo) {
this.currentVideo.magnet = metadata.magnet;
this.currentVideo.normalizedMagnet = metadata.normalizedMagnet;
this.currentVideo.normalizedMagnetFallback =
metadata.normalizedMagnetFallback;
if (metadata.legacyInfoHash && !this.currentVideo.legacyInfoHash) {
this.currentVideo.legacyInfoHash = metadata.legacyInfoHash;
}
this.currentVideo.torrentSupported = metadata.torrentSupported;
}
this.currentMagnetUri = metadata.magnet || null;
},
updateVideoModalStatus: (message) => {
if (this.videoModal) {
this.videoModal.updateStatus(message);
}
},
preparePlaybackLogging: (videoElement) =>
this.preparePlaybackLogging(videoElement),
forceRemoveModalPoster: (reason) => this.forceRemoveModalPoster(reason),
setTorrentStatsVisibility: (visible) => {
if (this.videoModal) {
this.videoModal.setTorrentStatsVisibility?.(visible);
}
},
});
},
async playVideoByEventId(eventId, playbackHint = {}) {
if (!eventId) {
this.showError("No video identifier provided.");
return;
}
const hint = playbackHint && typeof playbackHint === "object"
? playbackHint
: {};
const fallbackUrl =
typeof hint.url === "string" ? hint.url.trim() : "";
const fallbackTitle =
typeof hint.title === "string" ? hint.title : "";
const fallbackDescription =
typeof hint.description === "string" ? hint.description : "";
const fallbackMagnetRaw =
typeof hint.magnet === "string" ? hint.magnet.trim() : "";
let fallbackMagnetCandidate = "";
if (fallbackMagnetRaw) {
const decoded = safeDecodeMagnet(fallbackMagnetRaw);
fallbackMagnetCandidate = decoded || fallbackMagnetRaw;
}
const hasTrigger = Object.prototype.hasOwnProperty.call(hint, "trigger");
if (hasTrigger) {
this.setLastModalTrigger(hint.trigger);
} else {
this.setLastModalTrigger(null);
}
this.currentVideoPointer = null;
this.currentVideoPointerKey = null;
this.pendingModeratedPlayback = null;
if (this.blacklistedEventIds.has(eventId)) {
this.showError("This content has been removed or is not allowed.");
return;
}
let video = this.videosMap.get(eventId);
if (!video) {
video = await this.getOldEventById(eventId);
}
if (!video) {
if (fallbackUrl || fallbackMagnetCandidate) {
return this.playVideoWithoutEvent({
url: fallbackUrl,
magnet: fallbackMagnetCandidate,
title: fallbackTitle || "Untitled",
description: fallbackDescription || "",
trigger: hasTrigger ? hint.trigger : null,
});
}
this.showError("Video not found or has been removed.");
return;
}
try {
await accessControl.waitForReady();
} catch (error) {
devLogger.warn(
"Failed to ensure admin lists were loaded before playback:",
error
);
}
const authorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (!accessControl.canAccess(authorNpub)) {
// Dev diagnostic: pinpoint WHY access was denied (format mismatch vs the
// author genuinely not being on the list vs lists not loaded).
devLogger.warn("[playback] access denied — whitelist diagnostic", {
pubkey: video.pubkey,
authorNpub,
foreign: video.foreign === true || video.source === "nip71-ingest",
whitelistMode: accessControl.whitelistMode?.(),
whitelistNpubSize: accessControl.whitelist?.size,
whitelistHexSize: accessControl.whitelistPubkeys?.size,
inNpubSet: accessControl.whitelist?.has?.(authorNpub),
inHexSet: accessControl.whitelistPubkeys?.has?.(video.pubkey),
});
if (accessControl.isBlacklisted(authorNpub)) {
this.showError("This content has been removed or is not allowed.");
} else if (accessControl.whitelistMode()) {
this.showError("This content is not from a whitelisted author.");
} else {
this.showError("This content has been removed or is not allowed.");
}
return;
}
this.decorateVideoModeration(video);
let trimmedUrl = typeof video.url === "string" ? video.url.trim() : "";
if (!trimmedUrl && fallbackUrl) {
trimmedUrl = fallbackUrl;
}
const rawMagnet =
typeof video.magnet === "string" ? video.magnet.trim() : "";
let legacyInfoHash =
typeof video.infoHash === "string" ? video.infoHash.trim().toLowerCase() : "";
const fallbackMagnetForCandidate = fallbackMagnetCandidate || "";
if (!legacyInfoHash && fallbackMagnetForCandidate) {
const extracted = extractBtihFromMagnet(fallbackMagnetForCandidate);
if (extracted) {
legacyInfoHash = extracted;
}
}
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 : "";
const knownPostedAt = this.getKnownVideoPostedAt(video);
const normalizedEditedAt = Number.isFinite(video.created_at)
? Math.floor(video.created_at)
: null;
const normalizedCreatorPubkey =
this.normalizeHexPubkey(video.pubkey) || video.pubkey;
const cachedCreatorProfileEntry =
normalizedCreatorPubkey && typeof this.getProfileCacheEntry === "function"
? this.getProfileCacheEntry(normalizedCreatorPubkey)
: null;
const cachedCreatorProfile =
cachedCreatorProfileEntry &&
typeof cachedCreatorProfileEntry === "object"
? cachedCreatorProfileEntry.profile || null
: null;
const initialLightningAddress =
typeof video.lightningAddress === "string"
? video.lightningAddress.trim()
: "";
this.currentVideo = {
...video,
url: trimmedUrl,
magnet: sanitizedMagnet,
originalMagnet:
magnetCandidate || fallbackMagnetForCandidate || legacyInfoHash || "",
torrentSupported: magnetSupported,
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
lightningAddress: initialLightningAddress || null,
lastEditedAt: normalizedEditedAt,
};
this.decorateVideoModeration(this.currentVideo);
const modalTags = collectVideoTags(this.currentVideo);
this.currentVideo.displayTags = modalTags;
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
const displayNpub = formatShortNpub(creatorNpub) || creatorNpub;
const initialCreatorProfile = this.resolveModalCreatorProfile({
video: this.currentVideo,
pubkey: normalizedCreatorPubkey,
cachedProfile: cachedCreatorProfile,
});
this.currentVideo.creatorName = initialCreatorProfile.name;
this.currentVideo.creatorPicture = initialCreatorProfile.picture;
this.currentVideo.creatorNpub = displayNpub;
if (this.currentVideo.creator && typeof this.currentVideo.creator === "object") {
this.currentVideo.creator = {
...this.currentVideo.creator,
name: initialCreatorProfile.name,
picture: initialCreatorProfile.picture,
pubkey: normalizedCreatorPubkey,
};
} else {
this.currentVideo.creator = {
name: initialCreatorProfile.name,
picture: initialCreatorProfile.picture,
pubkey: normalizedCreatorPubkey,
};
}
this.updateModalSimilarContent({ activeVideo: this.currentVideo });
if (Number.isFinite(knownPostedAt)) {
this.cacheVideoRootCreatedAt(this.currentVideo, knownPostedAt);
} else if (this.currentVideo.rootCreatedAt) {
delete this.currentVideo.rootCreatedAt;
}
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
const pointerInfo = resolveVideoPointer({
kind: video.kind,
pubkey: video.pubkey,
videoRootId: video.videoRootId,
dTag: dTagValue,
fallbackEventId: video.id || eventId,
relay: hint.relay || video.relay,
});
this.currentVideoPointer = pointerInfo?.pointer || null;
this.currentVideoPointerKey = pointerInfo?.key || null;
if (this.currentVideo) {
this.currentVideo.pointer = this.currentVideoPointer;
this.currentVideo.pointerKey = this.currentVideoPointerKey;
}
const forYouState = this.getFeedTelemetryState("for-you");
if (
forYouState?.activePlayback?.feed === "for-you" &&
forYouState.activePlayback.videoId === eventId
) {
forYouState.activePlayback.pointerKey = this.currentVideoPointerKey || null;
}
this.subscribeModalViewCount(
this.currentVideoPointer,
this.currentVideoPointerKey
);
this.reactionController.subscribe(
this.currentVideoPointer,
this.currentVideoPointerKey
);
this.syncModalMoreMenuData();
this.currentMagnetUri = sanitizedMagnet || null;
// this.setCopyMagnetState(!!sanitizedMagnet); // Removed
// this.setShareButtonState(true); // Moved to after showModalWithPoster
const nevent = window.NostrTools.nip19.neventEncode({ id: eventId });
let pushUrl =
this.buildShareUrlFromNevent(nevent) ||
`${this.getShareUrlBase() || window.location.pathname}?v=${encodeURIComponent(
nevent
)}`;
try {
const targetUrl = new URL(pushUrl, window.location.origin);
if (targetUrl.origin !== window.location.origin) {
pushUrl = `${window.location.pathname}${targetUrl.search}${targetUrl.hash}`;
}
} catch (err) {
devLogger.warn("[Application] Failed to normalize pushState URL:", err);
}
window.history.pushState({}, "", pushUrl);
this.zapController?.resetState();
this.zapController?.setVisibility(Boolean(this.currentVideo.lightningAddress));
const magnetInput =
sanitizedMagnet ||
decodedMagnetCandidate ||
magnetCandidate ||
fallbackMagnetForCandidate ||
legacyInfoHash ||
"";
await this.showModalWithPoster(this.currentVideo);
this.setShareButtonState(true);
this.commentController?.load(this.currentVideo);
const playbackOptions = {
url: trimmedUrl,
magnet: magnetInput,
// Hosted mirrors (NIP-71 imeta variants) to fail over to mid-load.
sources: Array.isArray(this.currentVideo?.sources)
? this.currentVideo.sources
: [],
};
if (hasTrigger) {
playbackOptions.trigger = this.lastModalTrigger;
}
// Honor a forced-source deep link (e.g. ?playback=torrent). If the video
// actually has that source, force it so the test path is deterministic.
// If it does not, tell the user clearly and fall back to normal selection
// rather than opening a dead player.
const requestedSource = normalizeForcedSource(hint.forcedSource);
if (requestedSource === "torrent") {
if (sanitizedMagnet) {
playbackOptions.forcedSource = "torrent";
} else {
this.showError(
"This video has no WebTorrent source — nothing to stream over P2P.",
);
}
} else if (requestedSource === "url") {
if (trimmedUrl) {
playbackOptions.forcedSource = "url";
} else {
this.showError(
"This video has no hosted CDN URL — only a WebTorrent source is available.",
);
}
}
let playbackPromise = null;
if (this.shouldDeferModeratedPlayback(this.currentVideo)) {
const pendingVideoId =
(this.currentVideo && typeof this.currentVideo.id === "string" && this.currentVideo.id)
? this.currentVideo.id
: eventId || null;
this.pendingModeratedPlayback = {
...playbackOptions,
triggerProvided: hasTrigger,
videoId: pendingVideoId,
};
} else {
playbackPromise = this.playVideoWithFallback(playbackOptions);
}
if (this.videoModal) {
const timestampPayload = this.buildModalTimestampPayload({
postedAt: this.currentVideo?.rootCreatedAt ?? null,
editedAt: normalizedEditedAt,
});
this.videoModal.updateMetadata({
title: video.title || "Untitled",
description: video.description || "No description available.",
timestamps: timestampPayload,
tags: modalTags,
creator: {
name: initialCreatorProfile.name,
avatarUrl: initialCreatorProfile.picture,
npub: displayNpub,
},
});
}
const profileRequestToken = Symbol("modal-profile-request");
this.modalCreatorProfileRequestToken = profileRequestToken;
this.fetchModalCreatorProfile({
pubkey: normalizedCreatorPubkey,
displayNpub,
cachedProfile: cachedCreatorProfile,
requestToken: profileRequestToken,
}).catch((error) => {
devLogger.error(
"[Application] Failed to fetch creator profile for modal:",
error,
);
});
this.ensureModalPostedTimestamp(this.currentVideo);
const playbackResult =
playbackPromise && typeof playbackPromise.then === "function"
? await playbackPromise
: playbackPromise;
return playbackResult;
},
buildModalTimestampPayload({ postedAt = null, editedAt = null } = {}) {
const normalizedPostedAt = Number.isFinite(postedAt)
? Math.floor(postedAt)
: null;
const normalizedEditedAt = Number.isFinite(editedAt)
? Math.floor(editedAt)
: null;
const payload = {
posted: "",
edited: "",
};
const effectivePostedAt =
normalizedPostedAt !== null ? normalizedPostedAt : normalizedEditedAt;
if (effectivePostedAt !== null) {
payload.posted = `Posted ${this.formatTimeAgo(effectivePostedAt)}`;
}
const shouldShowEdited =
normalizedEditedAt !== null &&
(normalizedPostedAt === null || normalizedEditedAt - normalizedPostedAt >= 60);
if (shouldShowEdited) {
const abs = formatAbsoluteDateWithOrdinalUtil(normalizedEditedAt);
const rel = this.formatTimeAgo(normalizedEditedAt);
payload.edited = `Last edited: ${abs} (${rel})`;
}
return payload;
},
getKnownVideoPostedAt(video) {
if (!video || typeof video !== "object") {
return null;
}
const directValue = Number.isFinite(video.rootCreatedAt)
? Math.floor(video.rootCreatedAt)
: null;
if (directValue !== null) {
return directValue;
}
if (video.id && this.videosMap instanceof Map) {
const stored = this.videosMap.get(video.id);
const storedValue = Number.isFinite(stored?.rootCreatedAt)
? Math.floor(stored.rootCreatedAt)
: null;
if (storedValue !== null) {
video.rootCreatedAt = storedValue;
return storedValue;
}
}
const nip71Created = Number.isFinite(video?.nip71Source?.created_at)
? Math.floor(video.nip71Source.created_at)
: null;
if (nip71Created !== null) {
return nip71Created;
}
return null;
},
cacheVideoRootCreatedAt(video, timestamp) {
if (!Number.isFinite(timestamp)) {
return;
}
const normalized = Math.floor(timestamp);
const rootId = getVideoRootIdentifier(video);
if (video && typeof video === "object") {
video.rootCreatedAt = normalized;
}
applyRootTimestampToVideosMap({
videosMap: this.videosMap,
video,
rootId,
timestamp: normalized,
});
syncActiveVideoRootTimestamp({
activeVideo: this.currentVideo,
rootId,
timestamp: normalized,
buildModalTimestampPayload: (payload) =>
this.buildModalTimestampPayload(payload),
videoModal: this.videoModal,
});
if (nostrClient && typeof nostrClient.applyRootCreatedAt === "function") {
try {
nostrClient.applyRootCreatedAt(video);
} catch (error) {
devLogger.warn(
"[Application] Failed to sync cached root timestamp with nostrClient:",
error
);
}
}
},
async resolveVideoPostedAtBatch(videos) {
if (!Array.isArray(videos) || !videos.length) {
return new Map();
}
if (nostrClient && typeof nostrClient.hydrateVideoHistoryBatch === "function") {
try {
await nostrClient.hydrateVideoHistoryBatch(videos);
} catch (error) {
devLogger.warn(
"[Application] Failed to batch hydrate video histories:",
error
);
}
}
const results = new Map();
// Since history is hydrated, sequential processing is fast and safe
for (const video of videos) {
if (!video || !video.id) continue;
try {
const postedAt = await this.resolveVideoPostedAt(video);
if (Number.isFinite(postedAt)) {
results.set(video.id, postedAt);
}
} catch (error) {
devLogger.warn(
`[Application] Failed to resolve timestamp for video ${video.id}:`,
error
);
}
}
return results;
},
async resolveVideoPostedAt(video) {
if (!video || typeof video !== "object") {
return null;
}
// Prioritize NIP-71 published_at metadata if available
const rawNip71PublishedAt =
video?.nip71?.publishedAt ||
video?.nip71?.published_at ||
video?.nip71?.["published-at"];
const parsedNip71PublishedAt = Number(rawNip71PublishedAt);
const nip71PublishedAt = Number.isFinite(parsedNip71PublishedAt)
? Math.floor(parsedNip71PublishedAt)
: null;
if (nip71PublishedAt !== null) {
this.cacheVideoRootCreatedAt(video, nip71PublishedAt);
return nip71PublishedAt;
}
const cached = this.getKnownVideoPostedAt(video);
if (cached !== null) {
return cached;
}
if (!nostrClient || typeof nostrClient.hydrateVideoHistory !== "function") {
const fallback = Number.isFinite(video.created_at)
? Math.floor(video.created_at)
: null;
if (fallback !== null) {
this.cacheVideoRootCreatedAt(video, fallback);
}
return fallback;
}
try {
const history = await nostrClient.hydrateVideoHistory(video);
if (Array.isArray(history) && history.length) {
let earliest = null;
for (const entry of history) {
if (!entry || entry.deleted) {
continue;
}
const created = Number.isFinite(entry.created_at)
? Math.floor(entry.created_at)
: null;
if (created === null) {
continue;
}
if (earliest === null || created < earliest) {
earliest = created;
}
}
if (earliest === null) {
const lastEntry = history[history.length - 1];
if (Number.isFinite(lastEntry?.created_at)) {
earliest = Math.floor(lastEntry.created_at);
}
}
if (earliest !== null) {
this.cacheVideoRootCreatedAt(video, earliest);
return earliest;
}
}
} catch (error) {
devLogger.warn(
"[Application] Failed to hydrate video history for timestamps:",
error
);
}
const fallback = Number.isFinite(video.created_at)
? Math.floor(video.created_at)
: null;
if (fallback !== null) {
this.cacheVideoRootCreatedAt(video, fallback);
}
return fallback;
},
async ensureModalPostedTimestamp(video) {
if (!video || !this.videoModal) {
return;
}
const postedAt = await this.resolveVideoPostedAt(video);
if (!this.videoModal || this.currentVideo !== video) {
return;
}
const editedAt = Number.isFinite(video.lastEditedAt)
? Math.floor(video.lastEditedAt)
: Number.isFinite(video.created_at)
? Math.floor(video.created_at)
: null;
const payload = this.buildModalTimestampPayload({
postedAt,
editedAt,
});
const modalTags = collectVideoTags(video);
video.displayTags = modalTags;
this.updateModalSimilarContent({ activeVideo: video });
this.videoModal.updateMetadata({ timestamps: payload, tags: modalTags });
},
async playVideoWithoutEvent(options = {}) {
const {
url = "",
magnet = "",
title = "Untitled",
description = "",
trigger,
tags: rawTags,
} = options || {};
const hasTrigger = Object.prototype.hasOwnProperty.call(
options || {},
"trigger"
);
if (hasTrigger) {
this.setLastModalTrigger(trigger);
} else {
this.setLastModalTrigger(null);
}
this.currentVideoPointer = null;
this.currentVideoPointerKey = null;
this.subscribeModalViewCount(null, null);
this.reactionController.subscribe(null, null);
this.pendingModeratedPlayback = null;
const sanitizedUrl = typeof url === "string" ? url.trim() : "";
const trimmedMagnet = typeof magnet === "string" ? magnet.trim() : "";
const decodedMagnet = safeDecodeMagnet(trimmedMagnet);
const usableMagnet = decodedMagnet || trimmedMagnet;
const magnetSupported = isValidMagnetUri(usableMagnet);
const sanitizedMagnet = magnetSupported ? usableMagnet : "";
const modalTags = collectVideoTags({
nip71: { hashtags: rawTags },
});
this.zapController?.setVisibility(false);
this.zapController?.resetState();
if (!sanitizedUrl && !sanitizedMagnet) {
const message = trimmedMagnet && !magnetSupported
? UNSUPPORTED_BTITH_MESSAGE
: "This video has no playable source.";
this.showError(message);
return;
}
this.currentVideo = {
id: null,
title,
description,
url: sanitizedUrl,
magnet: sanitizedMagnet,
originalMagnet: trimmedMagnet,
torrentSupported: magnetSupported,
lightningAddress: null,
pointer: null,
pointerKey: null,
displayTags: modalTags,
};
this.decorateVideoModeration(this.currentVideo);
this.updateModalSimilarContent({ activeVideo: this.currentVideo });
this.syncModalMoreMenuData();
this.currentMagnetUri = sanitizedMagnet || null;
// this.setCopyMagnetState(!!sanitizedMagnet);
// this.setShareButtonState(false);
if (this.videoModal) {
this.videoModal.updateMetadata({
title: title || "Untitled",
description: description || "No description available.",
timestamp: "",
tags: modalTags,
creator: {
name: "Unknown",
avatarUrl: "assets/svg/default-profile.svg",
npub: "",
},
});
}
await this.showModalWithPoster(this.currentVideo, hasTrigger ? { trigger } : {});
this.setShareButtonState(false);
this.commentController?.load(null);
const shareUrl = this.buildShareUrlFromEventId(this.currentVideo.id);
const urlObj = new URL(window.location.href);
urlObj.searchParams.delete("v");
const cleaned = `${urlObj.pathname}${urlObj.search}${urlObj.hash}`;
window.history.replaceState({}, "", cleaned);
return this.playVideoWithFallback({
url: sanitizedUrl,
magnet: usableMagnet,
trigger: hasTrigger ? this.lastModalTrigger : null,
});
},
};
}