mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-31 09:52:24 +00:00
Add embed page for pointer playback
This commit is contained in:
1 parent
bb6a485618
commit
6ee9d44213
2 files changed
+526
No files matched your search
+105
@@ -0,0 +1,105 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>bitvid | Embed</title>
|
||||
|
||||
<meta name="description" content="Watch embedded bitvid videos." />
|
||||
<meta name="robots" content="noindex,follow" />
|
||||
|
||||
<meta name="theme-color" content="var(--color-page)" />
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const STORAGE_KEY = "bitvid:theme";
|
||||
const root = document.documentElement;
|
||||
const themeMeta = document.querySelector('meta[name="theme-color"]');
|
||||
const fallbackColors = {
|
||||
dark: "rgb(15, 23, 42)",
|
||||
light: "rgb(248, 250, 252)",
|
||||
};
|
||||
const prefersDark =
|
||||
window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
const storedTheme = window.localStorage.getItem(STORAGE_KEY);
|
||||
let resolvedTheme = storedTheme || (prefersDark ? "dark" : "light");
|
||||
|
||||
if (resolvedTheme === "default") {
|
||||
resolvedTheme = "dark";
|
||||
}
|
||||
|
||||
if (resolvedTheme) {
|
||||
root.dataset.theme = resolvedTheme;
|
||||
}
|
||||
|
||||
const updateThemeColor = () => {
|
||||
if (!themeMeta) {
|
||||
return;
|
||||
}
|
||||
|
||||
const computedThemeColor =
|
||||
(window.getComputedStyle &&
|
||||
getComputedStyle(root).getPropertyValue("--color-page").trim()) ||
|
||||
"";
|
||||
const metaColor =
|
||||
computedThemeColor ||
|
||||
fallbackColors[resolvedTheme] ||
|
||||
fallbackColors[prefersDark ? "dark" : "light"];
|
||||
|
||||
if (metaColor) {
|
||||
themeMeta.content = metaColor;
|
||||
}
|
||||
};
|
||||
|
||||
updateThemeColor();
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", updateThemeColor, {
|
||||
once: true,
|
||||
});
|
||||
} else if ("requestAnimationFrame" in window) {
|
||||
window.requestAnimationFrame(updateThemeColor);
|
||||
}
|
||||
} catch (error) {
|
||||
/* no-op */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link href="css/tailwind.generated.css?v=2024.11.09" rel="stylesheet" />
|
||||
</head>
|
||||
<body class="min-h-screen bg-surface text-text" data-ds="new">
|
||||
<main
|
||||
id="embedRoot"
|
||||
class="mx-auto flex min-h-screen w-full max-w-5xl flex-col px-4 py-6"
|
||||
>
|
||||
<section
|
||||
class="flex flex-col gap-4 rounded-2xl border border-border bg-surface-alt/60 p-4 shadow-sm"
|
||||
>
|
||||
<div
|
||||
class="aspect-video w-full overflow-hidden rounded-xl border border-border/70 bg-surface"
|
||||
>
|
||||
<video
|
||||
id="embedVideo"
|
||||
class="h-full w-full"
|
||||
controls
|
||||
playsinline
|
||||
preload="metadata"
|
||||
></video>
|
||||
</div>
|
||||
<p
|
||||
id="embedStatus"
|
||||
class="text-sm text-muted"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
Loading video…
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="js/nostrToolsBootstrap.js?v=2024.11.09"></script>
|
||||
<script type="module" src="js/embed.js?v=2024.11.09"></script>
|
||||
</body>
|
||||
</html>
|
||||
+421
@@ -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();
|
||||
Reference in new issue
Block a user