387 lines
13 KiB
TypeScript
387 lines
13 KiB
TypeScript
/**
|
|
* 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", "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 = `<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)}°)</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);
|