/**
* folk-jitsi-room — Jitsi Meet External API wrapper with 360° Director panel.
*
* Loads Jitsi External API script, creates meeting in shadowRoot.
* When director mode is active, shows HLS thumbnail strip for switching
* the local video track to a 360° perspective view.
*/
class FolkJitsiRoom extends HTMLElement {
private shadow: ShadowRoot;
private api: any = null;
private room = "";
private jitsiUrl = "";
private space = "";
private isDirector = false;
private sessionId = "";
// Director state
private directorViews: Array<{ index: number; label: string; yaw: number; hls_url: string }> = [];
private directorHlsPlayers: any[] = [];
private directorActiveView: number | null = null;
private directorCanvas: HTMLCanvasElement | null = null;
private directorStream: MediaStream | null = null;
private directorAnimFrame: number | null = null;
private directorError = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.room = this.getAttribute("room") || "";
this.jitsiUrl = this.getAttribute("jitsi-url") || "";
this.space = this.getAttribute("space") || "";
this.isDirector = this.getAttribute("director") === "1";
this.sessionId = this.getAttribute("session") || "";
this.render();
this.loadJitsiApi();
}
disconnectedCallback() {
this.dispose();
}
getApi() { return this.api; }
executeCommand(cmd: string, ...args: any[]) {
this.api?.executeCommand(cmd, ...args);
}
addJitsiListener(event: string, handler: (...args: any[]) => void) {
this.api?.addListener(event, handler);
}
private render() {
this.shadow.innerHTML = `
${this.isDirector && this.sessionId ? this.renderDirectorStrip() : ""}
`;
if (this.isDirector && this.sessionId) {
this.bindDirectorEvents();
}
}
private async loadJitsiApi() {
if (!this.jitsiUrl) return;
// Extract domain from URL
const jitsiDomain = this.jitsiUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
try {
// Load External API script
if (!(window as any).JitsiMeetExternalAPI) {
await new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = `${this.jitsiUrl}/external_api.min.js`;
script.onload = () => resolve();
script.onerror = () => reject(new Error("Failed to load Jitsi External API"));
document.head.appendChild(script);
});
}
const container = this.shadow.getElementById("jitsi-meet");
if (!container) return;
container.innerHTML = "";
this.api = new (window as any).JitsiMeetExternalAPI(jitsiDomain, {
roomName: this.room,
parentNode: container,
width: "100%",
height: "100%",
configOverwrite: {
prejoinConfig: { enabled: true },
disableDeepLinking: true,
hideConferenceSubject: false,
toolbarButtons: [
"camera", "chat", "closedcaptions", "desktop",
"fullscreen", "hangup", "microphone", "participants-pane",
"raisehand", "select-background", "settings",
"tileview", "toggle-camera",
],
},
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
SHOW_BRAND_WATERMARK: false,
},
});
// Emit custom events
this.api.addListener("videoConferenceJoined", (data: any) => {
this.dispatchEvent(new CustomEvent("jitsi-joined", { detail: data, bubbles: true }));
// If director mode, load the session views
if (this.isDirector && this.sessionId) {
this.loadDirectorSession();
}
});
this.api.addListener("videoConferenceLeft", (data: any) => {
this.dispatchEvent(new CustomEvent("jitsi-left", { detail: data, bubbles: true }));
});
this.api.addListener("participantJoined", (data: any) => {
this.dispatchEvent(new CustomEvent("jitsi-participant-joined", { detail: data, bubbles: true }));
});
} catch (e: any) {
const container = this.shadow.getElementById("jitsi-meet");
if (container) {
container.innerHTML = `Failed to load Jitsi: ${e.message}
`;
}
}
}
private dispose() {
this.destroyDirector();
if (this.api) {
try { this.api.dispose(); } catch {}
this.api = null;
}
}
// ── Director Panel (Phase 4a) ──
private renderDirectorStrip(): string {
if (this.directorError) {
return ``;
}
if (this.directorViews.length === 0) {
return `360 Director — connecting to session ${this.sessionId}...
`;
}
const thumbs = this.directorViews.map(v => `
${v.label} (${Math.round(v.yaw)}°)
`).join("");
const stopBtn = this.directorActiveView !== null
? ``
: "";
return `
360 Director
${thumbs}
${stopBtn}
`;
}
private bindDirectorEvents() {
this.shadow.querySelectorAll("[data-director-share]").forEach(btn => {
btn.addEventListener("click", (e) => {
e.stopPropagation();
const val = (btn as HTMLElement).dataset.directorShare;
if (val === "stop") {
this.stopDirectorShare();
} else {
this.shareDirectorView(parseInt(val || "0"));
}
});
});
}
private async loadDirectorSession() {
if (!this.sessionId) return;
const base = window.location.pathname.replace(/\/room\/.*$/, "");
// Find the rtube base path — go up to space level
const spacePath = `/${this.space}`;
const rtubeBase = `${spacePath}/rtube`;
try {
const resp = await fetch(`${rtubeBase}/api/live-split/status/${this.sessionId}`);
if (!resp.ok) {
this.directorError = "Could not connect to live-split session";
this.renderDirectorOnly();
return;
}
const data = await resp.json();
if (data.status !== "running") {
this.directorError = `Session status: ${data.status}`;
this.renderDirectorOnly();
return;
}
this.directorViews = data.views.map((v: any) => ({
index: v.index,
label: v.label,
yaw: v.yaw,
hls_url: v.hls_url,
}));
this.renderDirectorOnly();
// Attach HLS players to director thumbnails
setTimeout(() => this.attachDirectorHls(rtubeBase), 1000);
} catch (e: any) {
this.directorError = e.message || "Failed to load session";
this.renderDirectorOnly();
}
}
private renderDirectorOnly() {
// Re-render just the director strip without touching Jitsi
const existing = this.shadow.querySelector(".director-strip");
if (existing) existing.remove();
const container = this.shadow.getElementById("jitsi-meet");
if (!container) return;
const temp = document.createElement("div");
temp.innerHTML = this.isDirector && this.sessionId ? this.renderDirectorStrip() : "";
const strip = temp.firstElementChild;
if (strip) {
container.after(strip);
this.bindDirectorEvents();
}
}
private async attachDirectorHls(rtubeBase: string) {
const Hls = await this.loadHlsJs();
if (!Hls || !Hls.isSupported()) return;
this.destroyDirectorHls();
for (const view of this.directorViews) {
const video = this.shadow.getElementById(`director-video-${view.index}`) as HTMLVideoElement;
if (!video) continue;
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
enableWorker: true,
});
const hlsUrl = `${rtubeBase}/api/live-split/hls/${this.sessionId}/${view.hls_url.replace(/^.*?view_/, "view_")}`;
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
this.directorHlsPlayers.push(hls);
}
}
private async loadHlsJs(): Promise {
if ((window as any).Hls) return (window as any).Hls;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js";
script.onload = () => resolve((window as any).Hls);
script.onerror = () => reject(new Error("Failed to load HLS.js"));
document.head.appendChild(script);
});
}
private shareDirectorView(viewIndex: number) {
const video = this.shadow.getElementById(`director-video-${viewIndex}`) as HTMLVideoElement;
if (!video || !this.api) return;
// Create canvas for captureStream
if (!this.directorCanvas) {
this.directorCanvas = document.createElement("canvas");
this.directorCanvas.width = 1280;
this.directorCanvas.height = 720;
}
this.directorActiveView = viewIndex;
const ctx = this.directorCanvas.getContext("2d")!;
// Stop previous animation loop
if (this.directorAnimFrame) {
cancelAnimationFrame(this.directorAnimFrame);
}
// Draw video to canvas in a loop
const drawFrame = () => {
if (this.directorActiveView !== viewIndex) return;
ctx.drawImage(video, 0, 0, 1280, 720);
this.directorAnimFrame = requestAnimationFrame(drawFrame);
};
drawFrame();
// Get MediaStream from canvas
if (!this.directorStream) {
this.directorStream = this.directorCanvas.captureStream(30);
}
// Replace local video track in Jitsi
const videoTrack = this.directorStream.getVideoTracks()[0];
if (videoTrack) {
// Use Jitsi's iframe API to replace the video track
try {
this.api.executeCommand("overwriteLocalVideoTrack", videoTrack);
} catch {
// Fallback: some Jitsi versions use different API
try {
const iframe = this.shadow.querySelector("iframe") as HTMLIFrameElement;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({
type: "replace-local-track",
track: "video",
}, "*");
}
} catch {}
}
}
this.renderDirectorOnly();
}
private stopDirectorShare() {
if (this.directorAnimFrame) {
cancelAnimationFrame(this.directorAnimFrame);
this.directorAnimFrame = null;
}
this.directorActiveView = null;
this.directorStream = null;
// Restore camera
if (this.api) {
try {
this.api.executeCommand("toggleVideo");
setTimeout(() => this.api?.executeCommand("toggleVideo"), 100);
} catch {}
}
this.renderDirectorOnly();
}
private destroyDirectorHls() {
for (const hls of this.directorHlsPlayers) {
try { hls.destroy(); } catch {}
}
this.directorHlsPlayers = [];
}
private destroyDirector() {
if (this.directorAnimFrame) {
cancelAnimationFrame(this.directorAnimFrame);
this.directorAnimFrame = null;
}
this.directorStream = null;
this.directorCanvas = null;
this.destroyDirectorHls();
}
}
customElements.define("folk-jitsi-room", FolkJitsiRoom);