mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
buildShareUrlFromEventId encoded a BARE event id: no author, no relay hints. A viewer whose relay set did not happen to carry that event could not resolve it, so bitvid fell back to the home feed -- the "the shared link just opens bitvid.network" report. It is not a rare edge case. The reported video was present on nos.lol but absent from relay.damus.io, relay.snort.social and relay.primal.net -- three of bitvid's four bootstrap relays -- so most viewers arriving cold could not open it at all. The pointer now carries: - the video's author, pulled from videosMap, which also lets clients look the event up by author+kind if the id itself has aged out of a relay - up to three relay hints from the client's write relays (falling back to its general relay list), deduped and scheme-filtered Both are best-effort: an unknown video or a malformed pubkey still produces the old bare-id pointer rather than an invalid one, and no relays configured omits the hint instead of encoding an empty array. Also restores the path separator. The base URL has its trailing slash stripped, so shared links read `https://bitvid.network?v=...`. That is legal and browsers normalize it, but it looks like a typo in a note and some link parsers mis-detect where the URL ends. New tests fail 6/9 without the change. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
1450 lines
48 KiB
JavaScript
1450 lines
48 KiB
JavaScript
// js/app/playbackCoordinator.js
|
|
|
|
/**
|
|
* URL-first + magnet fallback playback pipeline.
|
|
*
|
|
* Stateful collaborators (clients, services, app state) are injected from the
|
|
* Application composition root rather than imported at module scope. Pure,
|
|
* stateless helper modules are imported directly, as in the other coordinators.
|
|
*
|
|
* Methods use `this` which is bound to the Application instance.
|
|
*/
|
|
import {
|
|
attachHlsSource,
|
|
detachHls,
|
|
isHlsUrl,
|
|
} from "../services/hlsPlayback.js";
|
|
|
|
/**
|
|
* 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 "";
|
|
}
|
|
// getShareUrlBase() strips the trailing slash, so add the path separator
|
|
// back. `https://bitvid.network?v=...` is legal and browsers normalize
|
|
// it, but it reads as a typo in a shared note and some link parsers
|
|
// mis-detect where the URL ends.
|
|
return `${base}/?v=${encodeURIComponent(nevent)}`;
|
|
},
|
|
|
|
/**
|
|
* Relay hints to embed in a shared pointer.
|
|
*
|
|
* Without these an `nevent` is just a bare id: a viewer whose relay set
|
|
* doesn't happen to carry the event cannot resolve it, and bitvid falls
|
|
* back to the home feed — the "shared link just opens bitvid.network"
|
|
* report. Capped to a few because the hints inflate every shared URL.
|
|
*/
|
|
getSharePointerRelays() {
|
|
const candidates = Array.isArray(nostrClient?.writeRelays)
|
|
? nostrClient.writeRelays
|
|
: Array.isArray(nostrClient?.relays)
|
|
? nostrClient.relays
|
|
: [];
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const entry of candidates) {
|
|
const url = typeof entry === "string" ? entry.trim() : "";
|
|
if (!url || !/^wss?:\/\//i.test(url) || seen.has(url)) {
|
|
continue;
|
|
}
|
|
seen.add(url);
|
|
out.push(url);
|
|
if (out.length >= 3) {
|
|
break;
|
|
}
|
|
}
|
|
return out;
|
|
},
|
|
|
|
buildShareUrlFromEventId(eventId) {
|
|
if (!eventId) {
|
|
return "";
|
|
}
|
|
|
|
try {
|
|
const pointer = { id: eventId };
|
|
|
|
// Author + relay hints make the pointer resolvable by someone who does
|
|
// not already follow this creator. The author also lets clients look
|
|
// the event up by author+kind if the id itself has aged out.
|
|
const known = this.videosMap?.get?.(eventId);
|
|
const author =
|
|
typeof known?.pubkey === "string" ? known.pubkey.trim() : "";
|
|
if (/^[0-9a-f]{64}$/i.test(author)) {
|
|
pointer.author = author.toLowerCase();
|
|
}
|
|
|
|
const relays = this.getSharePointerRelays();
|
|
if (relays.length) {
|
|
pointer.relays = relays;
|
|
}
|
|
|
|
const nevent = window.NostrTools.nip19.neventEncode(pointer);
|
|
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;
|
|
}
|
|
|
|
// HLS playlists cannot be assigned to `src` outside Safari; route them
|
|
// through hls.js/MSE (see js/services/hlsPlayback.js).
|
|
if (isHlsUrl(sanitizedUrl)) {
|
|
const handle = await attachHlsSource(target, sanitizedUrl, {
|
|
log: (...args) => devLogger.log(...args),
|
|
});
|
|
if (!handle) {
|
|
devLogger.warn(
|
|
"[playHttp] HLS source could not be attached:",
|
|
sanitizedUrl
|
|
);
|
|
return false;
|
|
}
|
|
} else {
|
|
detachHls(target);
|
|
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,
|
|
});
|
|
},
|
|
};
|
|
}
|