diff --git a/embed.html b/embed.html
new file mode 100644
index 00000000..e3408d8e
--- /dev/null
+++ b/embed.html
@@ -0,0 +1,105 @@
+
+
+
+
+
+ bitvid | Embed
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Loading video…
+
+
+
+
+
+
+
+
diff --git a/js/embed.js b/js/embed.js
new file mode 100644
index 00000000..abd48003
--- /dev/null
+++ b/js/embed.js
@@ -0,0 +1,421 @@
+import Application from "./app.js";
+import { nostrClient } from "./nostrClientFacade.js";
+import { convertEventToVideo, getDTagValueFromTags } from "./nostr/index.js";
+import { resolveVideoPointer } from "./utils/videoPointer.js";
+import { devLogger, userLogger } from "./utils/logger.js";
+import { nostrToolsReady } from "./nostrToolsBootstrap.js";
+
+const POINTER_PARAM = "pointer";
+const PLAYBACK_PARAM = "playback";
+const HEX64_REGEX = /^[0-9a-f]{64}$/i;
+
+const statusEl = document.getElementById("embedStatus");
+const videoEl = document.getElementById("embedVideo");
+const rootEl = document.getElementById("embedRoot");
+
+const toneClasses = {
+ default: "text-muted",
+ error: "text-danger",
+ success: "text-success",
+};
+
+const setStatus = (message, tone = "default") => {
+ if (!statusEl) {
+ return;
+ }
+
+ const normalizedMessage =
+ typeof message === "string" ? message.trim() : "";
+ statusEl.textContent = normalizedMessage;
+
+ Object.values(toneClasses).forEach((className) => {
+ if (className) {
+ statusEl.classList.remove(className);
+ }
+ });
+
+ const toneClass = toneClasses[tone] || toneClasses.default;
+ if (toneClass) {
+ statusEl.classList.add(toneClass);
+ }
+};
+
+class EmbedVideoModal {
+ constructor({ root, video, status } = {}) {
+ this.root = root || null;
+ this.video = video || null;
+ this.status = status || null;
+ this.eventTarget = new EventTarget();
+ }
+
+ addEventListener(...args) {
+ this.eventTarget.addEventListener(...args);
+ }
+
+ removeEventListener(...args) {
+ this.eventTarget.removeEventListener(...args);
+ }
+
+ dispatchEvent(event) {
+ return this.eventTarget.dispatchEvent(event);
+ }
+
+ getRoot() {
+ return this.root;
+ }
+
+ getVideoElement() {
+ return this.video;
+ }
+
+ setVideoElement(videoElement) {
+ if (videoElement instanceof HTMLVideoElement) {
+ this.video = videoElement;
+ }
+ return this.video;
+ }
+
+ updateStatus(message) {
+ setStatus(message, "default");
+ }
+
+ applyLoadingPoster() {
+ setStatus("Preparing playback…", "default");
+ }
+
+ forceRemovePoster() {
+ return false;
+ }
+
+ clearPosterCleanup() {}
+
+ setTorrentStatsVisibility(isVisible) {
+ if (!this.root) {
+ return;
+ }
+ this.root.dataset.torrentStats = isVisible ? "true" : "false";
+ }
+
+ open() {
+ return this.root;
+ }
+
+ close() {}
+
+ async load() {
+ if (!this.video || !(this.video instanceof HTMLVideoElement)) {
+ throw new Error("Embed video element is missing.");
+ }
+ return this.root;
+ }
+}
+
+const embedModal = new EmbedVideoModal({
+ root: rootEl,
+ video: videoEl,
+ status: statusEl,
+});
+
+const app = new Application({
+ ui: {
+ videoModal: embedModal,
+ },
+});
+
+app.showError = (message) => {
+ const normalizedMessage =
+ typeof message === "string" ? message.trim() : "";
+ if (normalizedMessage) {
+ userLogger.error(normalizedMessage);
+ }
+ setStatus(
+ normalizedMessage || "Playback failed. Please try again.",
+ "error",
+ );
+};
+
+app.showStatus = (message) => {
+ setStatus(message, "default");
+};
+
+app.showSuccess = (message) => {
+ setStatus(message, "success");
+};
+
+const resolvePlaybackPreference = (value) => {
+ if (typeof value !== "string") {
+ return null;
+ }
+ const normalized = value.trim().toLowerCase();
+ if (normalized === "url" || normalized === "torrent") {
+ return normalized;
+ }
+ return null;
+};
+
+const ensureNostrReady = async () => {
+ try {
+ await nostrToolsReady;
+ } catch (error) {
+ userLogger.warn("[embed] Failed to initialize NostrTools:", error);
+ }
+
+ try {
+ await nostrClient.ensurePool();
+ } catch (error) {
+ devLogger.warn("[embed] Failed to ensure Nostr pool:", error);
+ }
+
+ nostrClient.init().catch((error) => {
+ devLogger.warn("[embed] nostrClient.init failed:", error);
+ });
+};
+
+const decodePointer = (pointer) => {
+ const trimmed = typeof pointer === "string" ? pointer.trim() : "";
+ if (!trimmed) {
+ return null;
+ }
+
+ if (/^(naddr|nevent)/i.test(trimmed)) {
+ const decoder = window?.NostrTools?.nip19?.decode;
+ if (typeof decoder === "function") {
+ try {
+ return decoder(trimmed);
+ } catch (error) {
+ userLogger.warn("[embed] Failed to decode pointer:", error);
+ }
+ }
+ }
+
+ if (trimmed.includes(":")) {
+ return {
+ type: "naddr",
+ data: {
+ relay: null,
+ relays: [],
+ identifier: trimmed.split(":").slice(2).join(":"),
+ pubkey: trimmed.split(":")[1] || "",
+ kind: Number.parseInt(trimmed.split(":")[0], 10),
+ },
+ };
+ }
+
+ if (HEX64_REGEX.test(trimmed)) {
+ return {
+ type: "nevent",
+ data: {
+ id: trimmed,
+ relays: [],
+ },
+ };
+ }
+
+ return null;
+};
+
+const buildRelayList = (relays) => {
+ const combined = [
+ ...(Array.isArray(relays) ? relays : []),
+ ...(Array.isArray(nostrClient.relays) ? nostrClient.relays : []),
+ ];
+
+ return Array.from(
+ new Set(
+ combined
+ .map((relay) => (typeof relay === "string" ? relay.trim() : ""))
+ .filter(Boolean),
+ ),
+ );
+};
+
+const resolveEventFromPointer = async (pointer) => {
+ const decoded = decodePointer(pointer);
+ if (!decoded) {
+ return { video: null, pointerHint: null };
+ }
+
+ if (decoded.type === "nevent") {
+ const eventId = decoded.data?.id || "";
+ const relays = decoded.data?.relays || [];
+ let video = null;
+
+ if (eventId) {
+ if (relays.length) {
+ try {
+ const raw = await nostrClient.fetchRawEventById(eventId, {
+ relays: buildRelayList(relays),
+ });
+ if (raw) {
+ video = convertEventToVideo(raw);
+ }
+ } catch (error) {
+ devLogger.warn("[embed] Failed to fetch nevent via relays:", error);
+ }
+ }
+
+ if (!video) {
+ video = await nostrClient.getEventById(eventId);
+ }
+ }
+
+ return {
+ video,
+ pointerHint: {
+ relay: Array.isArray(relays) && relays.length ? relays[0] : null,
+ eventId,
+ },
+ };
+ }
+
+ if (decoded.type === "naddr") {
+ const data = decoded.data || {};
+ const kind = Number.isFinite(data.kind) ? data.kind : null;
+ const pubkey = typeof data.pubkey === "string" ? data.pubkey.trim() : "";
+ const identifier =
+ typeof data.identifier === "string" ? data.identifier.trim() : "";
+ const relays = Array.isArray(data.relays) ? data.relays : [];
+
+ if (!kind || !pubkey || !identifier) {
+ return { video: null, pointerHint: null };
+ }
+
+ await nostrClient.ensurePool();
+ const pool = nostrClient.pool;
+ const relayList = buildRelayList(relays);
+ const filter = {
+ kinds: [kind],
+ authors: [pubkey],
+ "#d": [identifier],
+ };
+
+ let rawEvent = null;
+ if (pool?.get && relayList.length) {
+ try {
+ rawEvent = await pool.get(relayList, filter);
+ } catch (error) {
+ devLogger.warn("[embed] Failed to fetch naddr via pool.get:", error);
+ }
+ }
+
+ if (!rawEvent && pool?.list && relayList.length) {
+ try {
+ const events = await pool.list(relayList, [filter]);
+ if (Array.isArray(events) && events.length) {
+ const flattened = events.flat().filter(Boolean);
+ rawEvent = flattened.sort(
+ (a, b) => (b.created_at || 0) - (a.created_at || 0),
+ )[0];
+ }
+ } catch (error) {
+ devLogger.warn("[embed] Failed to fetch naddr via pool.list:", error);
+ }
+ }
+
+ const video = rawEvent ? convertEventToVideo(rawEvent) : null;
+
+ return {
+ video,
+ pointerHint: {
+ relay: relayList.length ? relayList[0] : null,
+ dTag: identifier,
+ kind,
+ pubkey,
+ },
+ };
+ }
+
+ return { video: null, pointerHint: null };
+};
+
+const setPointerState = ({ video, hint } = {}) => {
+ if (!video) {
+ return;
+ }
+
+ const dTag = getDTagValueFromTags(video.tags) || hint?.dTag || "";
+ const pointerInfo = resolveVideoPointer({
+ kind: video.kind,
+ pubkey: video.pubkey,
+ videoRootId: video.videoRootId,
+ dTag,
+ fallbackEventId: video.id || hint?.eventId,
+ relay: hint?.relay || video.relay,
+ });
+
+ app.currentVideoPointer = pointerInfo?.pointer || null;
+ app.currentVideoPointerKey = pointerInfo?.key || null;
+
+ if (app.currentVideo) {
+ app.currentVideo.pointer = app.currentVideoPointer;
+ app.currentVideo.pointerKey = app.currentVideoPointerKey;
+ }
+};
+
+const startEmbed = async () => {
+ if (!(videoEl instanceof HTMLVideoElement)) {
+ setStatus("Embed video element is missing.", "error");
+ return;
+ }
+
+ const params = new URLSearchParams(window.location.search);
+ const pointer = params.get(POINTER_PARAM) || "";
+ const playback = resolvePlaybackPreference(params.get(PLAYBACK_PARAM));
+
+ if (!pointer) {
+ setStatus("Missing video pointer.", "error");
+ return;
+ }
+
+ setStatus("Resolving video…", "default");
+
+ try {
+ app.authService?.hydrateFromStorage?.();
+ } catch (error) {
+ devLogger.warn("[embed] Failed to hydrate auth state:", error);
+ }
+
+ await ensureNostrReady();
+
+ let resolved = null;
+ try {
+ resolved = await resolveEventFromPointer(pointer);
+ } catch (error) {
+ devLogger.warn("[embed] Failed to resolve pointer:", error);
+ }
+
+ const video = resolved?.video || null;
+ if (!video) {
+ setStatus("Unable to resolve the requested video.", "error");
+ return;
+ }
+
+ app.currentVideo = video;
+ setPointerState({ video, hint: resolved?.pointerHint || null });
+
+ const url = typeof video.url === "string" ? video.url.trim() : "";
+ const magnet = typeof video.magnet === "string" ? video.magnet.trim() : "";
+
+ if (!url && !magnet) {
+ setStatus("No playback sources found for this video.", "error");
+ return;
+ }
+
+ if (typeof video.title === "string" && video.title.trim()) {
+ document.title = `${video.title.trim()} | bitvid embed`;
+ }
+
+ try {
+ await app.playVideoWithFallback({
+ url,
+ magnet,
+ forcedSource: playback || undefined,
+ trigger: videoEl,
+ });
+ } catch (error) {
+ devLogger.warn("[embed] Playback failed:", error);
+ setStatus("Playback failed. Please try again.", "error");
+ }
+};
+
+startEmbed();