rspace-online/modules/rmeets/components/folk-jitsi-room.ts

391 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 = `
<style>
:host { display: block; width: 100%; min-height: 70vh; }
.jitsi-container { width: 100%; height: 70vh; border-radius: 12px; overflow: hidden; background: #000; }
.jitsi-container iframe { border: none !important; }
.loading { display: flex; align-items: center; justify-content: center; height: 70vh; color: var(--rs-text-muted, #888); font-family: system-ui, sans-serif; }
.director-strip { display: flex; gap: 6px; padding: 10px; background: var(--rs-bg-surface, #1a1a2e); border: 1px solid var(--rs-border, #333); border-radius: 0 0 12px 12px; overflow-x: auto; align-items: center; }
.director-thumb { position: relative; width: 160px; min-width: 160px; aspect-ratio: 16/9; border-radius: 6px; overflow: hidden; cursor: pointer; border: 2px solid transparent; background: #111; }
.director-thumb.active { border-color: #ef4444; }
.director-thumb video { width: 100%; height: 100%; object-fit: cover; pointer-events: none; }
.director-thumb-label { position: absolute; bottom: 0; left: 0; right: 0; background: rgba(0,0,0,0.7); color: #fff; font-size: 0.7rem; padding: 2px 6px; text-align: center; font-family: system-ui, sans-serif; }
.director-thumb-btn { position: absolute; top: 4px; right: 4px; background: rgba(239,68,68,0.9); color: #fff; border: none; border-radius: 4px; padding: 2px 6px; font-size: 0.65rem; cursor: pointer; font-family: system-ui, sans-serif; }
.director-thumb-btn:hover { background: #ef4444; }
.director-info { font-size: 0.75rem; color: var(--rs-text-muted, #888); padding: 0 8px; white-space: nowrap; font-family: system-ui, sans-serif; }
.director-error { font-size: 0.8rem; color: #ef4444; padding: 8px; font-family: system-ui, sans-serif; }
</style>
<div class="jitsi-container" id="jitsi-meet">
<div class="loading">Loading Jitsi Meet...</div>
</div>
${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<void>((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", "microphone", "desktop", "hangup",
"raisehand", "tileview", "toggle-camera",
"fullscreen", "select-background",
],
// Hide panels that add stray close (×) buttons
disableChat: false,
participantsPane: { enabled: false },
},
interfaceConfigOverwrite: {
SHOW_JITSI_WATERMARK: false,
SHOW_WATERMARK_FOR_GUESTS: false,
SHOW_BRAND_WATERMARK: false,
CLOSE_PAGE_GUEST_HINT: false,
SHOW_PROMOTIONAL_CLOSE_PAGE: 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 = `<div class="loading" style="color:#ef4444">Failed to load Jitsi: ${e.message}</div>`;
}
}
}
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 `<div class="director-strip"><div class="director-error">${this.directorError}</div></div>`;
}
if (this.directorViews.length === 0) {
return `<div class="director-strip"><div class="director-info">360 Director — connecting to session ${this.sessionId}...</div></div>`;
}
const thumbs = this.directorViews.map(v => `
<div class="director-thumb ${this.directorActiveView === v.index ? "active" : ""}" data-director-view="${v.index}">
<video id="director-video-${v.index}" muted autoplay playsinline></video>
<div class="director-thumb-label">${v.label} (${Math.round(v.yaw)}&deg;)</div>
<button class="director-thumb-btn" data-director-share="${v.index}">Share</button>
</div>
`).join("");
const stopBtn = this.directorActiveView !== null
? `<button class="director-thumb-btn" data-director-share="stop" style="position:static;padding:4px 10px;font-size:0.75rem">Stop Sharing</button>`
: "";
return `<div class="director-strip">
<div class="director-info">360 Director</div>
${thumbs}
${stopBtn}
</div>`;
}
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<any> {
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);