Files
bitvid/js/app/playbackCoordinator.js
T
thePR0M3TH3ANandClaude Opus 4.8 d23cfa6697 feat(blossom): P2P for Blossom videos via a WebTorrent-metadata companion event (#30)
Blossom servers reject a .torrent (415), so a Blossom video's magnet is webseed-only and can't bootstrap WebTorrent. This restores P2P without hosting a .torrent:

- Upload hardening: correct Content-Type (overrides empty/generic application/octet-stream), HTTP status surfaced in errors, and best-effort .torrent sidecar so a rejected sidecar no longer sinks the upload (URL-first fallback).

- Publish: when the .torrent can't be hosted, publish the piece-map as an infohash-keyed companion event (kind 30078, d=bitvid:torrent:<infohash>) and ship a ws= magnet. Gated by FEATURE_BLOSSOM_TORRENT_METADATA (default off = no trace); size-capped.

- Playback: torrentMetadataService lazily fetches the companion, VERIFIES the reconstructed infohash === the magnet's btih (untrusted content), sources the webseed from the video event, and hands the .torrent buffer to WebTorrent so it streams with zero peers. Relay reads go through the subscription manager.

Inherited by any upload-based media (shorts, audio-publish); excludes ingest/live. See docs/blossom-torrent-metadata-plan.md. Includes a Prettier reflow in css/tailwind.source.css.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-11 14:08:53 -04:00

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.modalVideo.muted = true;
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,
});
},
};
}