mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 02:14:01 +00:00
913 lines
32 KiB
JavaScript
913 lines
32 KiB
JavaScript
import * as Cesium from 'cesium';
|
|
|
|
const YOUTUBE_HOSTS = new Set([
|
|
'youtube.com',
|
|
'www.youtube.com',
|
|
'm.youtube.com',
|
|
'youtube-nocookie.com',
|
|
'www.youtube-nocookie.com',
|
|
'youtu.be',
|
|
]);
|
|
const FACEBOOK_HOSTS = new Set([
|
|
'facebook.com',
|
|
'www.facebook.com',
|
|
'm.facebook.com',
|
|
'web.facebook.com',
|
|
'fb.watch',
|
|
'www.fb.watch',
|
|
]);
|
|
const X_HOSTS = new Set([
|
|
'x.com',
|
|
'www.x.com',
|
|
'mobile.x.com',
|
|
'twitter.com',
|
|
'www.twitter.com',
|
|
'mobile.twitter.com',
|
|
]);
|
|
const X_WIDGET_URL = 'https://platform.twitter.com/widgets.js';
|
|
const FACEBOOK_SDK_ID = 'facebook-jssdk';
|
|
const FACEBOOK_SDK_URL = 'https://connect.facebook.net/en_US/sdk.js#xfbml=0&version=v24.0';
|
|
const EMBED_TIMEOUT_MS = 15000;
|
|
const CARD_EXIT_DURATION_MS = 620;
|
|
const MEDIA_PRECONNECT_ORIGINS = Object.freeze({
|
|
youtube: Object.freeze([
|
|
'https://www.youtube-nocookie.com',
|
|
'https://www.youtube.com',
|
|
'https://i.ytimg.com',
|
|
]),
|
|
facebook: Object.freeze(['https://www.facebook.com']),
|
|
});
|
|
|
|
let _xWidgetsPromise = null;
|
|
let _facebookSdkPromise = null;
|
|
let _facebookEmbedSequence = 0;
|
|
const youtubeApis = new WeakMap();
|
|
|
|
function loadYouTubeApi(documentRef, globalRef) {
|
|
if (typeof globalRef.YT?.Player === 'function') return Promise.resolve(globalRef.YT);
|
|
if (youtubeApis.has(documentRef)) return youtubeApis.get(documentRef);
|
|
const pending = new Promise((resolve, reject) => {
|
|
const previous = globalRef.onYouTubeIframeAPIReady;
|
|
let timer;
|
|
let settled = false;
|
|
const script = documentRef.createElement('script');
|
|
const finish = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
globalRef.clearTimeout(timer);
|
|
if (globalRef.onYouTubeIframeAPIReady === ready) globalRef.onYouTubeIframeAPIReady = previous;
|
|
if (error) { script.remove(); reject(error); }
|
|
else resolve(globalRef.YT);
|
|
};
|
|
const ready = () => {
|
|
try { previous?.(); } finally {
|
|
finish(typeof globalRef.YT?.Player === 'function' ? null : new Error('YouTube API unavailable'));
|
|
}
|
|
};
|
|
globalRef.onYouTubeIframeAPIReady = ready;
|
|
script.src = 'https://www.youtube.com/iframe_api';
|
|
script.async = true;
|
|
script.addEventListener('error', () => finish(new Error('YouTube API unavailable')), { once: true });
|
|
timer = globalRef.setTimeout(() => finish(new Error('YouTube API timed out')), 5000);
|
|
documentRef.head.append(script);
|
|
}).catch((error) => { youtubeApis.delete(documentRef); throw error; });
|
|
youtubeApis.set(documentRef, pending);
|
|
return pending;
|
|
}
|
|
|
|
/** A trimmed clip follows provider time, never time spent loading its iframe. */
|
|
export function createTrimmedYouTubePlayback({
|
|
iframe, endAtSec, startAtSec = 0, loadApi, globalRef = globalThis,
|
|
isCurrent = () => true, onStatus = () => {},
|
|
}) {
|
|
let player = null;
|
|
let phase = 'starting';
|
|
let active = true;
|
|
let started = false;
|
|
let currentTime = startAtSec;
|
|
let poll = null;
|
|
let deadline = null;
|
|
const live = () => active && isCurrent() && ['starting', 'playing'].includes(phase);
|
|
const clear = () => {
|
|
globalRef.clearTimeout(deadline);
|
|
globalRef.clearTimeout(poll);
|
|
deadline = poll = null;
|
|
};
|
|
const finish = (outcome) => {
|
|
if (!live()) return;
|
|
phase = outcome;
|
|
clear();
|
|
try { player?.pauseVideo?.(); } catch { /* Removed provider frame. */ }
|
|
onStatus(outcome);
|
|
};
|
|
const sample = (stateHint) => {
|
|
if (!live()) return;
|
|
globalRef.clearTimeout(poll);
|
|
poll = null;
|
|
try {
|
|
const previousTime = currentTime;
|
|
const time = player?.getCurrentTime?.();
|
|
if (Number.isFinite(time)) currentTime = time;
|
|
const state = stateHint ?? player?.getPlayerState?.();
|
|
if (currentTime >= endAtSec || (state === 0 && currentTime >= endAtSec - 0.1)) {
|
|
finish('completed'); return;
|
|
}
|
|
if (state === 0) { finish('unavailable'); return; }
|
|
// Autoplay can begin before the API attaches, without a new PLAYING edge.
|
|
if (!started && (state === 1 || currentTime > previousTime)) {
|
|
started = true;
|
|
phase = 'playing';
|
|
globalRef.clearTimeout(deadline);
|
|
deadline = globalRef.setTimeout(() => finish('timeout'), (endAtSec - startAtSec) * 1000 + 5000);
|
|
}
|
|
} catch { finish('unavailable'); return; }
|
|
poll = globalRef.setTimeout(() => sample(), 100);
|
|
};
|
|
deadline = globalRef.setTimeout(() => finish('timeout'), 5000);
|
|
Promise.resolve().then(loadApi).then((api) => {
|
|
if (!live()) return;
|
|
player = new api.Player(iframe, { events: {
|
|
onReady(event) {
|
|
if (!live()) return;
|
|
player = event.target;
|
|
sample();
|
|
if (!live()) return;
|
|
try { event.target.mute(); event.target.playVideo(); } catch { finish('unavailable'); }
|
|
},
|
|
onStateChange(event) {
|
|
if (!live()) return;
|
|
if (event.target) player = event.target;
|
|
sample(event.data);
|
|
},
|
|
onError: () => finish('unavailable'),
|
|
onAutoplayBlocked: () => finish('blocked'),
|
|
} });
|
|
}).catch(() => finish('unavailable'));
|
|
return {
|
|
getState: () => ({ phase, currentTime }),
|
|
destroy() {
|
|
active = false;
|
|
phase = 'cancelled';
|
|
clear();
|
|
try { player?.destroy?.(); } catch { /* Detached frame already released. */ }
|
|
player = null;
|
|
},
|
|
};
|
|
}
|
|
|
|
function facebookSdkReady(api) {
|
|
return typeof api?.XFBML?.parse === 'function'
|
|
&& typeof api?.Event?.subscribe === 'function';
|
|
}
|
|
|
|
function loadFacebookSdk(documentRef, globalRef = globalThis) {
|
|
if (facebookSdkReady(globalRef.FB)) return Promise.resolve(globalRef.FB);
|
|
if (_facebookSdkPromise) return _facebookSdkPromise;
|
|
_facebookSdkPromise = new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timer = null;
|
|
const prior = globalRef.fbAsyncInit;
|
|
const finish = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
globalRef.clearTimeout?.(timer);
|
|
if (globalRef.fbAsyncInit === ready) globalRef.fbAsyncInit = prior;
|
|
if (error) reject(error);
|
|
else resolve(globalRef.FB);
|
|
};
|
|
const ready = () => {
|
|
try {
|
|
prior?.();
|
|
} finally {
|
|
if (facebookSdkReady(globalRef.FB)) finish();
|
|
else finish(new Error('Facebook player API did not initialize.'));
|
|
}
|
|
};
|
|
globalRef.fbAsyncInit = ready;
|
|
let script = documentRef.getElementById?.(FACEBOOK_SDK_ID);
|
|
if (!script) {
|
|
script = documentRef.createElement('script');
|
|
script.id = FACEBOOK_SDK_ID;
|
|
script.async = true;
|
|
script.defer = true;
|
|
script.crossOrigin = 'anonymous';
|
|
script.src = FACEBOOK_SDK_URL;
|
|
documentRef.head?.append(script);
|
|
}
|
|
script.addEventListener?.(
|
|
'error',
|
|
() => finish(new Error('Facebook player API could not load.')),
|
|
{ once: true },
|
|
);
|
|
timer = globalRef.setTimeout?.(
|
|
() => finish(new Error('Facebook player API timed out.')),
|
|
EMBED_TIMEOUT_MS,
|
|
) ?? null;
|
|
}).catch((error) => {
|
|
_facebookSdkPromise = null;
|
|
throw error;
|
|
});
|
|
return _facebookSdkPromise;
|
|
}
|
|
|
|
/** Accept a public HTTPS media URL without allowing iframe markup or credentials. */
|
|
export function publicEmbeddedMediaUrl(value) {
|
|
if (typeof value !== 'string' || value.length > 4096) return null;
|
|
let url;
|
|
try {
|
|
url = new URL(value.trim());
|
|
} catch {
|
|
return null;
|
|
}
|
|
const host = url.hostname.toLowerCase();
|
|
if (
|
|
url.protocol !== 'https:'
|
|
|| url.username
|
|
|| url.password
|
|
|| (url.port && url.port !== '443')
|
|
|| !host.includes('.')
|
|
|| host.includes(':')
|
|
|| /^\d+(\.\d+){3}$/.test(host)
|
|
|| /(^|\.)(localhost|local|internal|test|invalid)$/.test(host)
|
|
) return null;
|
|
return url;
|
|
}
|
|
|
|
function youtubeVideoId(url) {
|
|
const host = url.hostname.toLowerCase();
|
|
if (!YOUTUBE_HOSTS.has(host)) return null;
|
|
if (host === 'youtu.be') return /^\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
|
|
if (url.pathname === '/watch') return url.searchParams.get('v');
|
|
return /^\/(?:embed|shorts)\/([\w-]{11})\/?$/.exec(url.pathname)?.[1] || null;
|
|
}
|
|
|
|
function isFacebookVideoUrl(url) {
|
|
const host = url.hostname.toLowerCase();
|
|
if (!FACEBOOK_HOSTS.has(host)) return false;
|
|
if (host === 'fb.watch' || host === 'www.fb.watch') return /^\/[\w.-]+\/?$/.test(url.pathname);
|
|
return /^\/reel\/[\w.-]+\/?$/.test(url.pathname)
|
|
|| (url.pathname === '/watch/' && Boolean(url.searchParams.get('v')))
|
|
|| /\/videos\/[\w.-]+\/?$/.test(url.pathname)
|
|
|| /^\/share\/(?:v|r)\/[\w.-]+\/?$/.test(url.pathname);
|
|
}
|
|
|
|
function xPostId(url) {
|
|
if (!X_HOSTS.has(url.hostname.toLowerCase())) return null;
|
|
return /^\/[A-Za-z0-9_]{1,15}\/status\/(\d+)\/?$/.exec(url.pathname)?.[1] || null;
|
|
}
|
|
|
|
/** Resolve only providers that can render inside a Bhote Koshi callout. */
|
|
export function resolveEmbeddedMediaSource(value) {
|
|
const url = publicEmbeddedMediaUrl(value);
|
|
if (!url) return null;
|
|
const youtubeId = youtubeVideoId(url);
|
|
if (youtubeId && /^[\w-]{11}$/.test(youtubeId)) {
|
|
return {
|
|
provider: 'youtube',
|
|
id: youtubeId,
|
|
url: `https://www.youtube.com/watch?v=${youtubeId}`,
|
|
};
|
|
}
|
|
if (isFacebookVideoUrl(url)) {
|
|
return { provider: 'facebook', url: url.href };
|
|
}
|
|
const postId = xPostId(url);
|
|
if (postId) {
|
|
return { provider: 'x', id: postId, url: url.href };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Provider-owned iframe URL for YouTube or Facebook callouts. */
|
|
export function embeddedMediaFrameUrl(source, {
|
|
autoplay = false,
|
|
startAtSec = 0,
|
|
endAtSec = null,
|
|
} = {}) {
|
|
if (source?.provider === 'youtube') {
|
|
const url = new URL(`https://www.youtube-nocookie.com/embed/${source.id}`);
|
|
url.searchParams.set('playsinline', '1');
|
|
url.searchParams.set('rel', '0');
|
|
const startSeconds = Math.max(0, Math.floor(Number(startAtSec) || 0));
|
|
if (startSeconds > 0) url.searchParams.set('start', String(startSeconds));
|
|
const endSeconds = Math.floor(Number(endAtSec));
|
|
if (Number.isFinite(endSeconds) && endSeconds > startSeconds) {
|
|
url.searchParams.set('end', String(endSeconds));
|
|
}
|
|
if (autoplay) {
|
|
url.searchParams.set('autoplay', '1');
|
|
url.searchParams.set('mute', '1');
|
|
}
|
|
return url.href;
|
|
}
|
|
if (source?.provider === 'facebook') {
|
|
const url = new URL('https://www.facebook.com/plugins/video.php');
|
|
url.searchParams.set('href', source.url);
|
|
url.searchParams.set('show_text', 'false');
|
|
url.searchParams.set('width', '500');
|
|
url.searchParams.set('autoplay', autoplay ? 'true' : 'false');
|
|
url.searchParams.set('mute', autoplay ? 'true' : 'false');
|
|
return url.href;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function xWidgetsReady(api) {
|
|
return typeof api?.widgets?.createTweet === 'function';
|
|
}
|
|
|
|
function loadXWidgets(documentRef, globalRef = globalThis) {
|
|
if (xWidgetsReady(globalRef.twttr)) return Promise.resolve(globalRef.twttr);
|
|
if (_xWidgetsPromise) return _xWidgetsPromise;
|
|
_xWidgetsPromise = new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timer = null;
|
|
const finish = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
if (error) reject(error);
|
|
else resolve(globalRef.twttr);
|
|
};
|
|
const ready = () => {
|
|
if (xWidgetsReady(globalRef.twttr)) finish();
|
|
else finish(new Error('X embed API did not initialize.'));
|
|
};
|
|
let script = documentRef.querySelector?.(`script[src="${X_WIDGET_URL}"]`);
|
|
if (!script) {
|
|
script = documentRef.createElement('script');
|
|
script.src = X_WIDGET_URL;
|
|
script.async = true;
|
|
script.charset = 'utf-8';
|
|
script.dataset.gevXWidgets = 'true';
|
|
documentRef.head?.append(script);
|
|
}
|
|
script.addEventListener?.('error', () => finish(new Error('X embed could not load.')), {
|
|
once: true,
|
|
});
|
|
const waitForApi = () => {
|
|
if (xWidgetsReady(globalRef.twttr)) {
|
|
finish();
|
|
return;
|
|
}
|
|
if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
|
|
else ready();
|
|
};
|
|
script.addEventListener?.('load', waitForApi, { once: true });
|
|
if (typeof globalRef.twttr?.ready === 'function') globalRef.twttr.ready(ready);
|
|
if (script.dataset?.gevXLoaded === 'true') waitForApi();
|
|
else script.addEventListener?.('load', () => { script.dataset.gevXLoaded = 'true'; }, {
|
|
once: true,
|
|
});
|
|
timer = setTimeout(() => finish(new Error('X embed timed out.')), EMBED_TIMEOUT_MS);
|
|
}).catch((error) => {
|
|
_xWidgetsPromise = null;
|
|
throw error;
|
|
});
|
|
return _xWidgetsPromise;
|
|
}
|
|
|
|
function providerLabel(provider) {
|
|
return ({ youtube: 'YOUTUBE', facebook: 'FACEBOOK', x: 'X' })[provider] || 'SOURCE';
|
|
}
|
|
|
|
function isPortraitMedia(media = {}) {
|
|
const width = Number(media.width);
|
|
const height = Number(media.height);
|
|
return String(media.orientation || '').toLowerCase() === 'portrait'
|
|
|| (Number.isFinite(width) && Number.isFinite(height) && height > width);
|
|
}
|
|
|
|
function ensureProviderConnections(documentRef, provider) {
|
|
for (const origin of MEDIA_PRECONNECT_ORIGINS[provider] || []) {
|
|
if (documentRef.querySelector?.(`link[data-gev-media-preconnect="${origin}"]`)) continue;
|
|
const link = documentRef.createElement('link');
|
|
link.rel = 'preconnect';
|
|
link.href = origin;
|
|
link.crossOrigin = 'anonymous';
|
|
link.dataset.gevMediaPreconnect = origin;
|
|
documentRef.head?.append(link);
|
|
}
|
|
}
|
|
|
|
function anchorOnScreen(viewer, anchor, root) {
|
|
if (!viewer?.scene || !anchor || !root) return null;
|
|
const camera = viewer.camera;
|
|
if (camera?.positionWC && camera?.directionWC) {
|
|
const delta = Cesium.Cartesian3.subtract(anchor, camera.positionWC, new Cesium.Cartesian3());
|
|
if (Cesium.Cartesian3.dot(delta, camera.directionWC) <= 0) return null;
|
|
}
|
|
const pixel = Cesium.SceneTransforms.worldToWindowCoordinates(viewer.scene, anchor);
|
|
if (!pixel) return null;
|
|
const canvasBounds = viewer.canvas?.getBoundingClientRect?.();
|
|
const rootBounds = root.getBoundingClientRect?.();
|
|
if (!canvasBounds || !rootBounds) return null;
|
|
const x = pixel.x + canvasBounds.left - rootBounds.left;
|
|
const y = pixel.y + canvasBounds.top - rootBounds.top;
|
|
if (x < -40 || y < -40 || x > rootBounds.width + 40 || y > rootBounds.height + 40) return null;
|
|
return { x, y, width: rootBounds.width, height: rootBounds.height };
|
|
}
|
|
|
|
function placeCard(card, leader, anchor) {
|
|
const margin = 18;
|
|
const gap = 42;
|
|
const width = card.offsetWidth || 392;
|
|
const height = card.offsetHeight || 360;
|
|
const rootBounds = card.parentElement?.getBoundingClientRect?.();
|
|
const documentRef = card.ownerDocument;
|
|
let laneLeft = margin;
|
|
let laneRight = anchor.width - margin;
|
|
let laneBottom = anchor.height - margin;
|
|
if (rootBounds && documentRef?.querySelector) {
|
|
const leftPanel = documentRef.querySelector('#scene-panel')?.getBoundingClientRect?.();
|
|
const rightRail = documentRef.querySelector('#right-context-rail')?.getBoundingClientRect?.();
|
|
const commandDock = documentRef.querySelector('#command-dock')?.getBoundingClientRect?.();
|
|
if (leftPanel?.width > 0) laneLeft = Math.max(laneLeft, leftPanel.right - rootBounds.left + margin);
|
|
if (rightRail?.width > 0) laneRight = Math.min(laneRight, rightRail.left - rootBounds.left - margin);
|
|
if (commandDock?.height > 0) laneBottom = Math.min(
|
|
laneBottom,
|
|
commandDock.top - rootBounds.top - margin,
|
|
);
|
|
if (laneRight - laneLeft < width) {
|
|
laneLeft = margin;
|
|
laneRight = anchor.width - margin;
|
|
}
|
|
}
|
|
let left = anchor.x + gap;
|
|
if (left + width > laneRight) left = anchor.x - width - gap;
|
|
left = Math.max(laneLeft, Math.min(laneRight - width, left));
|
|
const belowAnchor = anchor.y + gap;
|
|
const prefersBelow = card.classList.contains('is-youtube')
|
|
|| card.classList.contains('is-portrait');
|
|
const desiredTop = prefersBelow && belowAnchor + height <= laneBottom
|
|
? belowAnchor
|
|
: anchor.y - height * 0.42;
|
|
const top = Math.max(margin, Math.min(laneBottom - height, desiredTop));
|
|
card.style.left = `${left}px`;
|
|
card.style.top = `${top}px`;
|
|
const localX = anchor.x - left;
|
|
const localY = anchor.y - top;
|
|
const edgeX = localX < 0 ? 0 : width;
|
|
const edgeY = Math.max(16, Math.min(height - 16, localY));
|
|
leader.querySelector('path')?.setAttribute('d', `M ${localX} ${localY} L ${edgeX} ${edgeY}`);
|
|
const dot = leader.querySelector('circle');
|
|
dot?.setAttribute('cx', String(localX));
|
|
dot?.setAttribute('cy', String(localY));
|
|
}
|
|
|
|
function createRoot(documentRef, viewer) {
|
|
const root = documentRef.createElement('div');
|
|
root.id = 'bhote-koshi-embedded-media-root';
|
|
root.setAttribute('aria-live', 'polite');
|
|
(viewer?.container || documentRef.body)?.append(root);
|
|
return root;
|
|
}
|
|
|
|
/**
|
|
* Render one provider-owned player/post in a geographically anchored DOM card.
|
|
* YouTube and Facebook sources use their provider player; approved local clips
|
|
* remain available to bhoteKoshiEvent as a fallback when no embed is supported.
|
|
*/
|
|
export function createBhoteKoshiEmbeddedMedia({
|
|
viewer,
|
|
documentRef = globalThis.document,
|
|
globalRef = globalThis,
|
|
xLoader = loadXWidgets,
|
|
facebookLoader = loadFacebookSdk,
|
|
youtubeLoader = loadYouTubeApi,
|
|
} = {}) {
|
|
if (!documentRef?.createElement || !viewer) {
|
|
return {
|
|
warm: () => false,
|
|
show: () => false,
|
|
play: () => false,
|
|
pause: () => false,
|
|
hide: () => {},
|
|
destroy: () => {},
|
|
};
|
|
}
|
|
const root = createRoot(documentRef, viewer);
|
|
let record = null;
|
|
let warmRecord = null;
|
|
let generation = 0;
|
|
const leavingRecords = new Set();
|
|
|
|
const prefersReducedMotion = () => (
|
|
globalRef.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches === true
|
|
);
|
|
const scheduleFrame = (callback) => {
|
|
if (typeof globalRef.requestAnimationFrame === 'function') {
|
|
return { kind: 'frame', id: globalRef.requestAnimationFrame(callback) };
|
|
}
|
|
if (typeof globalRef.setTimeout === 'function') {
|
|
return { kind: 'timer', id: globalRef.setTimeout(callback, 0) };
|
|
}
|
|
callback();
|
|
return null;
|
|
};
|
|
const cancelScheduledFrame = (scheduled) => {
|
|
if (!scheduled) return;
|
|
if (scheduled.kind === 'frame') globalRef.cancelAnimationFrame?.(scheduled.id);
|
|
else globalRef.clearTimeout?.(scheduled.id);
|
|
};
|
|
|
|
function unsubscribeFacebook(session) {
|
|
if (!session?.readyListener) return;
|
|
try {
|
|
session.api?.Event?.unsubscribe?.('xfbml.ready', session.readyListener);
|
|
} catch {
|
|
// Provider teardown is best effort.
|
|
}
|
|
session.readyListener = null;
|
|
}
|
|
|
|
function setFacebookSessionPlayback(session, playing) {
|
|
if (!session?.active) return false;
|
|
session.shouldPlay = playing === true;
|
|
if (!session.player) return false;
|
|
try {
|
|
if (session.shouldPlay) {
|
|
session.player.mute?.();
|
|
const result = session.player.play?.();
|
|
result?.catch?.(() => {
|
|
if (!session.active || !session.shouldPlay) return;
|
|
session.onPlaybackBlocked?.();
|
|
});
|
|
} else {
|
|
session.player.pause?.();
|
|
}
|
|
return true;
|
|
} catch {
|
|
if (session.shouldPlay) session.onPlaybackBlocked?.();
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function destroyFacebookSession(session) {
|
|
if (!session) return;
|
|
session.shouldPlay = false;
|
|
globalRef.clearTimeout?.(session.timer);
|
|
session.timer = null;
|
|
try {
|
|
session.player?.pause?.();
|
|
} catch {
|
|
// Provider teardown is best effort.
|
|
}
|
|
session.active = false;
|
|
unsubscribeFacebook(session);
|
|
session.host?.remove?.();
|
|
session.player = null;
|
|
}
|
|
|
|
function createFacebookSession(source, host) {
|
|
const session = {
|
|
active: true,
|
|
api: null,
|
|
embed: null,
|
|
host,
|
|
player: null,
|
|
ready: false,
|
|
error: null,
|
|
readyListener: null,
|
|
shouldPlay: false,
|
|
timer: null,
|
|
onReady: null,
|
|
onError: null,
|
|
onPlaybackBlocked: null,
|
|
};
|
|
const embed = documentRef.createElement('div');
|
|
embed.id = `bhote-koshi-facebook-${++_facebookEmbedSequence}`;
|
|
embed.className = 'fb-video';
|
|
embed.dataset.href = source.url;
|
|
embed.dataset.width = '500';
|
|
embed.dataset.showText = 'false';
|
|
embed.dataset.allowfullscreen = 'true';
|
|
embed.dataset.autoplay = 'false';
|
|
session.embed = embed;
|
|
host.append(embed);
|
|
void facebookLoader(documentRef, globalRef).then((api) => {
|
|
if (!session.active) return;
|
|
session.api = api;
|
|
session.readyListener = (message) => {
|
|
if (!session.active || message?.type !== 'video'
|
|
|| (message.id && message.id !== embed.id)) return;
|
|
globalRef.clearTimeout?.(session.timer);
|
|
session.timer = null;
|
|
session.player = message.instance;
|
|
session.ready = true;
|
|
session.error = null;
|
|
session.onReady?.();
|
|
setFacebookSessionPlayback(session, session.shouldPlay);
|
|
};
|
|
api.Event.subscribe('xfbml.ready', session.readyListener);
|
|
api.XFBML.parse(session.host);
|
|
session.timer = globalRef.setTimeout?.(() => {
|
|
if (!session.active || session.ready) return;
|
|
session.error = new Error(
|
|
'Facebook embed unavailable. The post may be private, removed, region-restricted, or blocked.',
|
|
);
|
|
session.onError?.(session.error);
|
|
}, EMBED_TIMEOUT_MS) ?? null;
|
|
}).catch((error) => {
|
|
if (!session.active) return;
|
|
session.error = error;
|
|
session.onError?.(error);
|
|
});
|
|
return session;
|
|
}
|
|
|
|
function removeRecord(current) {
|
|
if (!current) return;
|
|
cancelScheduledFrame(current.enterFrame);
|
|
if (current.exitTimer != null) globalRef.clearTimeout?.(current.exitTimer);
|
|
destroyFacebookSession(current.facebookSession);
|
|
current.youtubeSession?.destroy();
|
|
current.card?.remove?.();
|
|
leavingRecords.delete(current);
|
|
}
|
|
|
|
const position = () => {
|
|
if (!record) return;
|
|
const projected = anchorOnScreen(viewer, record.anchor, root);
|
|
record.card.hidden = !projected;
|
|
if (projected) {
|
|
placeCard(record.card, record.leader, projected);
|
|
if (!record.entered) {
|
|
record.entered = true;
|
|
const current = record;
|
|
const token = generation;
|
|
current.enterFrame = scheduleFrame(() => {
|
|
current.enterFrame = null;
|
|
if (token !== generation || record !== current) return;
|
|
current.card.classList.add('is-entering');
|
|
});
|
|
}
|
|
}
|
|
};
|
|
const removePostRender = viewer.scene?.postRender?.addEventListener?.(position);
|
|
|
|
function hide({ immediate = false } = {}) {
|
|
generation += 1;
|
|
const current = record;
|
|
record = null;
|
|
if (!current) return;
|
|
current.youtubeSession?.destroy();
|
|
setFacebookSessionPlayback(current.facebookSession, false);
|
|
cancelScheduledFrame(current.enterFrame);
|
|
current.enterFrame = null;
|
|
if (immediate || current.card.hidden || prefersReducedMotion()) {
|
|
removeRecord(current);
|
|
return;
|
|
}
|
|
current.card.classList.remove('is-entering');
|
|
current.card.classList.add('is-leaving');
|
|
leavingRecords.add(current);
|
|
current.exitTimer = globalRef.setTimeout?.(
|
|
() => removeRecord(current),
|
|
CARD_EXIT_DURATION_MS,
|
|
) ?? null;
|
|
}
|
|
|
|
function clearWarm(key = null) {
|
|
if (!warmRecord || (key && warmRecord.key !== key)) return;
|
|
if (warmRecord.facebookSession) destroyFacebookSession(warmRecord.facebookSession);
|
|
else warmRecord.iframe?.remove?.();
|
|
warmRecord = null;
|
|
}
|
|
|
|
function takeWarmFacebook(key, host) {
|
|
if (warmRecord?.key !== key || !warmRecord.facebookSession) return null;
|
|
const session = warmRecord.facebookSession;
|
|
warmRecord = null;
|
|
const warmHost = session.host;
|
|
warmHost.classList.remove('bhote-embedded-media-warmup');
|
|
warmHost.classList.add('bhote-facebook-player-host');
|
|
warmHost.removeAttribute?.('aria-hidden');
|
|
host.append(warmHost);
|
|
return session;
|
|
}
|
|
|
|
function warm({ observation, sourceUrl } = {}) {
|
|
const media = observation?.media || {};
|
|
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
|
|
if (!source || source.provider === 'x') {
|
|
clearWarm();
|
|
return false;
|
|
}
|
|
const startAtSec = source.provider === 'youtube'
|
|
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
|
|
: 0;
|
|
ensureProviderConnections(documentRef, source.provider);
|
|
const endAtSec = media.embedEndAtSec;
|
|
const key = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
|
|
if (warmRecord?.key === key) return true;
|
|
clearWarm();
|
|
if (source.provider === 'facebook') {
|
|
const host = documentRef.createElement('div');
|
|
host.className = 'bhote-embedded-media-warmup';
|
|
host.setAttribute('aria-hidden', 'true');
|
|
root.append(host);
|
|
const facebookSession = createFacebookSession(source, host);
|
|
warmRecord = { key, facebookSession };
|
|
return true;
|
|
}
|
|
const iframe = documentRef.createElement('iframe');
|
|
iframe.className = 'bhote-embedded-media-warmup';
|
|
iframe.src = embeddedMediaFrameUrl(source, { autoplay: false, startAtSec, endAtSec });
|
|
iframe.title = `Preloading ${providerLabel(source.provider)} media`;
|
|
iframe.tabIndex = -1;
|
|
iframe.setAttribute('aria-hidden', 'true');
|
|
iframe.loading = 'eager';
|
|
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
root.append(iframe);
|
|
warmRecord = { key, iframe };
|
|
return true;
|
|
}
|
|
|
|
async function mountX(current, token) {
|
|
current.status.textContent = 'LOADING X POST…';
|
|
try {
|
|
const api = await xLoader(documentRef, globalRef);
|
|
if (token !== generation || record !== current) return;
|
|
const widget = await api.widgets.createTweet(current.source.id, current.player, {
|
|
align: 'center',
|
|
conversation: 'none',
|
|
dnt: true,
|
|
theme: 'dark',
|
|
width: 390,
|
|
});
|
|
if (token !== generation || record !== current) return;
|
|
if (!widget) throw new Error('The X post is unavailable or cannot be embedded.');
|
|
current.status.textContent = 'X POST · PROVIDER CONTROLS';
|
|
position();
|
|
} catch (error) {
|
|
if (token !== generation || record !== current) return;
|
|
current.card.classList.add('is-unavailable');
|
|
current.status.textContent = `${String(error?.message || 'X embed unavailable')} OPEN ORIGINAL.`;
|
|
position();
|
|
}
|
|
}
|
|
|
|
function bindFacebook(current, token, warmKey, autoplay) {
|
|
const session = takeWarmFacebook(warmKey, current.player)
|
|
|| createFacebookSession(current.source, current.player);
|
|
current.facebookSession = session;
|
|
session.shouldPlay = autoplay === true;
|
|
session.onReady = () => {
|
|
if (token !== generation || record !== current) return;
|
|
current.status.textContent = 'FACEBOOK · PROVIDER CONTROLS';
|
|
current.card.classList.remove('is-unavailable');
|
|
position();
|
|
};
|
|
session.onError = (error) => {
|
|
if (token !== generation || record !== current) return;
|
|
current.card.classList.add('is-unavailable');
|
|
current.status.textContent = `${String(error?.message || 'Facebook embed unavailable')} OPEN ORIGINAL.`;
|
|
position();
|
|
};
|
|
session.onPlaybackBlocked = () => {
|
|
if (token !== generation || record !== current) return;
|
|
current.status.textContent = 'AUTOPLAY BLOCKED · USE PROVIDER CONTROLS';
|
|
};
|
|
if (session.ready) session.onReady();
|
|
else if (session.error) session.onError(session.error);
|
|
setFacebookSessionPlayback(session, autoplay);
|
|
}
|
|
|
|
function setPlayback(playing) {
|
|
// Removing an uncontrolled provider frame also cancels delayed autoplay.
|
|
// Provider commands alone cannot stop a frame that has not loaded yet.
|
|
if (!playing && record && !record.facebookSession) {
|
|
hide({ immediate: true });
|
|
return true;
|
|
}
|
|
if (!record?.facebookSession) return false;
|
|
return setFacebookSessionPlayback(record.facebookSession, playing);
|
|
}
|
|
|
|
function show({ observation, anchor, autoplay = false, sourceUrl } = {}) {
|
|
const media = observation?.media || {};
|
|
const source = resolveEmbeddedMediaSource(sourceUrl || media.sourceUrl);
|
|
if (!source || !anchor) {
|
|
hide();
|
|
return false;
|
|
}
|
|
const startAtSec = source.provider === 'youtube'
|
|
? Math.max(0, Number(media.embedStartAtSec ?? media.clipInSec) || 0)
|
|
: 0;
|
|
ensureProviderConnections(documentRef, source.provider);
|
|
const endAtSec = media.embedEndAtSec;
|
|
const warmKey = `${source.provider}:${source.id || source.url}:${startAtSec}:${endAtSec ?? ''}`;
|
|
const key = source.provider === 'facebook'
|
|
? warmKey
|
|
: `${warmKey}:${autoplay ? 1 : 0}`;
|
|
if (record?.key === key) {
|
|
record.anchor = anchor;
|
|
if (source.provider === 'facebook') setPlayback(autoplay);
|
|
position();
|
|
return true;
|
|
}
|
|
hide();
|
|
const token = ++generation;
|
|
const card = documentRef.createElement('section');
|
|
const portraitClass = isPortraitMedia(media) ? ' is-portrait' : '';
|
|
card.className = `bhote-embedded-callout is-${source.provider}${portraitClass}`;
|
|
card.dataset.provider = source.provider;
|
|
card.setAttribute('aria-label', `Embedded ${providerLabel(source.provider)} media for ${observation?.title || 'witness source'}`);
|
|
const leader = documentRef.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
leader.classList.add('bhote-embedded-callout-leader');
|
|
leader.setAttribute('aria-hidden', 'true');
|
|
leader.innerHTML = '<path pathLength="1" fill="none" stroke="rgba(147,213,228,.72)" stroke-width="1.5"/><circle r="4" fill="#6be8ff" stroke="#ffffff" stroke-width="1.5"/>';
|
|
const player = documentRef.createElement('div');
|
|
player.className = 'bhote-embedded-callout-player';
|
|
const footer = documentRef.createElement('footer');
|
|
const title = documentRef.createElement('strong');
|
|
title.textContent = String(observation?.shortTitle || observation?.title || 'Witness source').toUpperCase();
|
|
const status = documentRef.createElement('small');
|
|
status.setAttribute('role', 'status');
|
|
status.textContent = `${providerLabel(source.provider)} · EMBEDDED SOURCE`;
|
|
const link = documentRef.createElement('a');
|
|
link.href = source.url;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.textContent = 'OPEN ORIGINAL ↗';
|
|
footer.append(title, status, link);
|
|
const body = documentRef.createElement('div');
|
|
body.className = 'bhote-embedded-callout-body';
|
|
body.append(player, footer);
|
|
card.append(leader, body);
|
|
root.append(card);
|
|
record = {
|
|
key,
|
|
source,
|
|
anchor,
|
|
card,
|
|
leader,
|
|
player,
|
|
status,
|
|
entered: false,
|
|
};
|
|
|
|
if (source.provider === 'x') {
|
|
void mountX(record, token);
|
|
} else if (source.provider === 'facebook') {
|
|
bindFacebook(record, token, warmKey, autoplay);
|
|
} else {
|
|
const iframe = documentRef.createElement('iframe');
|
|
iframe.src = embeddedMediaFrameUrl(source, { autoplay, startAtSec, endAtSec });
|
|
const controlled = autoplay && Number.isFinite(Number(endAtSec)) && Number(endAtSec) > startAtSec;
|
|
if (controlled) {
|
|
const url = new URL(iframe.src);
|
|
url.searchParams.set('enablejsapi', '1');
|
|
if (globalRef.location?.origin) url.searchParams.set('origin', globalRef.location.origin);
|
|
iframe.src = url.href;
|
|
}
|
|
iframe.title = `${providerLabel(source.provider)} media: ${observation?.title || 'witness source'}`;
|
|
iframe.allow = 'autoplay; encrypted-media; picture-in-picture; fullscreen';
|
|
iframe.allowFullscreen = true;
|
|
iframe.loading = 'eager';
|
|
iframe.referrerPolicy = 'strict-origin-when-cross-origin';
|
|
iframe.addEventListener?.('load', () => {
|
|
if (token !== generation || record?.key !== key) return;
|
|
clearWarm(warmKey);
|
|
if (!record.youtubeSession || ['starting', 'playing'].includes(record.youtubeSession.getState().phase)) {
|
|
status.textContent = `${providerLabel(source.provider)} · PROVIDER CONTROLS`;
|
|
}
|
|
position();
|
|
}, { once: true });
|
|
player.append(iframe);
|
|
if (controlled) {
|
|
const current = record;
|
|
current.youtubeSession = createTrimmedYouTubePlayback({
|
|
iframe, endAtSec: Number(endAtSec), startAtSec, globalRef,
|
|
loadApi: () => youtubeLoader(documentRef, globalRef),
|
|
isCurrent: () => token === generation && record === current,
|
|
onStatus: (phase) => {
|
|
if (phase !== 'completed') status.textContent = `YOUTUBE ${phase.toUpperCase()} · OPEN ORIGINAL`;
|
|
},
|
|
});
|
|
}
|
|
}
|
|
position();
|
|
return true;
|
|
}
|
|
|
|
function destroy() {
|
|
hide({ immediate: true });
|
|
for (const current of [...leavingRecords]) removeRecord(current);
|
|
clearWarm();
|
|
removePostRender?.();
|
|
root.remove();
|
|
}
|
|
|
|
return {
|
|
warm,
|
|
show,
|
|
getPlaybackState: () => record?.youtubeSession?.getState() || null,
|
|
play: () => setPlayback(true),
|
|
pause: () => setPlayback(false),
|
|
hide,
|
|
destroy,
|
|
};
|
|
}
|