/** * 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 = `
Loading Jitsi Meet...
${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 `
${this.directorError}
`; } 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);