diff --git a/modules/rmeets/components/folk-jitsi-room.ts b/modules/rmeets/components/folk-jitsi-room.ts
new file mode 100644
index 0000000..8ae607a
--- /dev/null
+++ b/modules/rmeets/components/folk-jitsi-room.ts
@@ -0,0 +1,386 @@
+/**
+ * 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 ``;
+ }
+
+ 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);
diff --git a/modules/rmeets/mod.ts b/modules/rmeets/mod.ts
index e795d79..d8cdc6b 100644
--- a/modules/rmeets/mod.ts
+++ b/modules/rmeets/mod.ts
@@ -19,6 +19,22 @@ const routes = new Hono();
routes.get("/room/:room", (c) => {
const space = c.req.param("space") || "demo";
const room = c.req.param("room");
+ const useApi = c.req.query("api") === "1";
+
+ if (useApi) {
+ const director = c.req.query("director") === "1";
+ const sessionId = c.req.query("session") || "";
+ return c.html(renderShell({
+ title: `${room} — rMeets | rSpace`,
+ moduleId: "rmeets",
+ spaceSlug: space,
+ modules: getModuleInfoList(),
+ theme: "dark",
+ body: ``,
+ scripts: ``,
+ }));
+ }
+
return c.html(renderExternalAppShell({
title: `${room} — rMeets | rSpace`,
moduleId: "rmeets",
diff --git a/modules/rtube/components/folk-video-player.ts b/modules/rtube/components/folk-video-player.ts
index f952954..54c93f6 100644
--- a/modules/rtube/components/folk-video-player.ts
+++ b/modules/rtube/components/folk-video-player.ts
@@ -13,7 +13,7 @@ class FolkVideoPlayer extends HTMLElement {
private space = "demo";
private videos: Array<{ name: string; size: number; duration?: string; date?: string }> = [];
private currentVideo: string | null = null;
- private mode: "library" | "live" = "library";
+ private mode: "library" | "live" | "360live" = "library";
private streamKey = "";
private searchTerm = "";
private isDemo = false;
@@ -23,6 +23,15 @@ class FolkVideoPlayer extends HTMLElement {
private splitPollInterval: ReturnType | null = null;
private splitImporting = false;
private splitImported: string[] | null = null;
+ // 360 Live state
+ private liveSplitSession: { sessionId: string; status: string; views: Array<{ index: number; label: string; yaw: number; hls_url: string; alive?: boolean }> } | null = null;
+ private liveSplitStreamKey = "";
+ private liveSplitSettings = { numViews: 4, hFov: 90, vFov: 90, overlap: 0, outputRes: "" };
+ private liveSplitError = "";
+ private liveSplitStarting = false;
+ private hlsPlayers: any[] = [];
+ private liveSplitStatusInterval: ReturnType | null = null;
+ private expandedView: number | null = null;
private _tour!: TourEngine;
private static readonly TOUR_STEPS = [
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
@@ -148,21 +157,36 @@ class FolkVideoPlayer extends HTMLElement {
.split-results li::before { content: ""; display: inline-block; width: 6px; height: 6px; background: #22c55e; border-radius: 50%; margin-right: 0.5rem; vertical-align: middle; }
.btn-success { padding: 0.75rem 1.5rem; background: #22c55e; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; width: 100%; font-size: 0.875rem; margin-top: 0.75rem; }
.btn-success:disabled { opacity: 0.5; cursor: not-allowed; }
- @media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
+ .live360-section { max-width: 960px; margin: 0 auto; }
+ .live360-grid { display: grid; gap: 4px; margin-top: 1rem; }
+ .live360-grid.views-2 { grid-template-columns: 1fr 1fr; }
+ .live360-grid.views-3 { grid-template-columns: 1fr 1fr 1fr; }
+ .live360-grid.views-4 { grid-template-columns: 1fr 1fr; }
+ .live360-grid.views-6 { grid-template-columns: 1fr 1fr 1fr; }
+ .live360-cell { position: relative; background: #111; border-radius: 8px; overflow: hidden; aspect-ratio: 16/9; cursor: pointer; }
+ .live360-cell.expanded { grid-column: 1 / -1; aspect-ratio: 21/9; }
+ .live360-cell video { width: 100%; height: 100%; object-fit: cover; }
+ .live360-label { position: absolute; top: 8px; left: 8px; background: rgba(0,0,0,0.7); color: #fff; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; display: flex; align-items: center; gap: 6px; pointer-events: none; }
+ .live360-label .live-dot-sm { width: 6px; height: 6px; background: #ef4444; border-radius: 50%; animation: pulse 2s infinite; }
+ .live360-controls { display: flex; gap: 0.75rem; align-items: center; margin-top: 1rem; }
+ .live360-status { font-size: 0.8rem; color: var(--rs-text-secondary); flex: 1; }
+ @media (max-width: 768px) { .layout { grid-template-columns: 1fr; } .live360-grid.views-3, .live360-grid.views-6 { grid-template-columns: 1fr 1fr; } }
${!this.isDemo ? `` : ""}
+ ${!this.isDemo ? `` : ""}
${this.isDemo ? `${this.videos.length} recordings` : ""}
- ${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
+ ${this.mode === "library" ? this.renderLibrary() : this.mode === "live" ? this.renderLive() : this.render360Live()}
${this.splitModalOpen ? this.renderSplitModal() : ""}
`;
this.bindEvents();
this.bindSplitEvents();
+ if (this.mode === "360live") this.bind360LiveEvents();
this._tour.renderOverlay();
}
@@ -252,12 +276,298 @@ class FolkVideoPlayer extends HTMLElement {
`;
}
+ private render360Live(): string {
+ if (this.liveSplitSession && this.liveSplitSession.status === "running") {
+ return this.render360LiveRunning();
+ }
+ if (this.liveSplitError) {
+ return `
+
+
+
Error
+
${this.liveSplitError}
+
+
+
+ `;
+ }
+ // Setup form
+ const s = this.liveSplitSettings;
+ return `
+
+
+
360° Live Stream Splitter
+
Split a live 360° RTMP stream into multiple perspective views in real-time.
+
+
+
+
+ ${[2, 3, 4, 6].map(n => ``).join("")}
+
+
+
+
+
+
Broadcaster Setup
+
+ - Stream 360° video to
rtmp://rtube.online:1936/live/{key}
+ - Enter the same stream key above
+ - Choose number of views and click Start
+ - Each view shows a different perspective of the 360° stream
+
+
+
+ `;
+ }
+
+ private render360LiveRunning(): string {
+ const session = this.liveSplitSession!;
+ const viewCount = session.views.length;
+ const gridClass = `views-${viewCount}`;
+ const cells = session.views.map(v => {
+ const isExpanded = this.expandedView === v.index;
+ return `
+
+
+
+
+ ${v.label} (${Math.round(v.yaw)}°)
+
+
+ `;
+ }).join("");
+
+ return `
+
+
${cells}
+
+
+ Live
+ Session: ${session.sessionId} · ${viewCount} views
+
+
+
+
+ `;
+ }
+
+ 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 async attachHlsPlayers() {
+ if (!this.liveSplitSession) return;
+ const Hls = await this.loadHlsJs();
+ if (!Hls.isSupported()) {
+ this.liveSplitError = "HLS.js is not supported in this browser";
+ this.render();
+ return;
+ }
+
+ this.destroyHlsPlayers();
+ const base = window.location.pathname.replace(/\/$/, "");
+
+ for (const view of this.liveSplitSession.views) {
+ const video = this.shadow.getElementById(`live360-video-${view.index}`) as HTMLVideoElement;
+ if (!video) continue;
+
+ const hls = new Hls({
+ liveSyncDurationCount: 3,
+ liveMaxLatencyDurationCount: 6,
+ enableWorker: true,
+ });
+ const hlsUrl = `${base}/api/live-split/hls/${this.liveSplitSession.sessionId}/${view.hls_url.replace(/^.*?view_/, "view_")}`;
+ hls.loadSource(hlsUrl);
+ hls.attachMedia(video);
+ hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
+ this.hlsPlayers.push(hls);
+ }
+ }
+
+ private destroyHlsPlayers() {
+ for (const hls of this.hlsPlayers) {
+ try { hls.destroy(); } catch {}
+ }
+ this.hlsPlayers = [];
+ if (this.liveSplitStatusInterval) {
+ clearInterval(this.liveSplitStatusInterval);
+ this.liveSplitStatusInterval = null;
+ }
+ }
+
+ private async startLiveSplit() {
+ if (!this.liveSplitStreamKey) return;
+ const base = window.location.pathname.replace(/\/$/, "");
+ const s = this.liveSplitSettings;
+
+ this.liveSplitStarting = true;
+ this.liveSplitError = "";
+ this.render();
+
+ try {
+ const resp = await authFetch(`${base}/api/live-split`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ streamKey: this.liveSplitStreamKey,
+ numViews: s.numViews,
+ hFov: s.hFov,
+ vFov: s.vFov,
+ overlap: s.overlap || undefined,
+ outputRes: s.outputRes || undefined,
+ }),
+ });
+
+ if (!resp.ok) {
+ const data = await resp.json().catch(() => ({ error: "Request failed" }));
+ throw new Error(data.error || `HTTP ${resp.status}`);
+ }
+
+ const data = await resp.json();
+ this.liveSplitSession = {
+ sessionId: data.session_id,
+ status: data.status,
+ views: data.views.map((v: any) => ({
+ index: v.index,
+ label: v.label,
+ yaw: v.yaw,
+ hls_url: v.hls_url,
+ })),
+ };
+ this.liveSplitStarting = false;
+ this.render();
+
+ // Wait a moment for FFmpeg to produce initial segments
+ setTimeout(() => this.attachHlsPlayers(), 4000);
+ this.startLiveSplitStatusPoll();
+ } catch (e: any) {
+ this.liveSplitStarting = false;
+ this.liveSplitError = e.message || "Failed to start live split";
+ this.render();
+ }
+ }
+
+ private startLiveSplitStatusPoll() {
+ if (this.liveSplitStatusInterval) clearInterval(this.liveSplitStatusInterval);
+ const base = window.location.pathname.replace(/\/$/, "");
+ const sessionId = this.liveSplitSession?.sessionId;
+ if (!sessionId) return;
+
+ this.liveSplitStatusInterval = setInterval(async () => {
+ try {
+ const resp = await fetch(`${base}/api/live-split/status/${sessionId}`);
+ if (!resp.ok) return;
+ const data = await resp.json();
+ if (data.status === "error") {
+ this.liveSplitError = data.error || "Stream ended unexpectedly";
+ this.liveSplitSession = null;
+ this.destroyHlsPlayers();
+ this.render();
+ }
+ } catch { /* retry */ }
+ }, 5000);
+ }
+
+ private async stopLiveSplit() {
+ if (!this.liveSplitSession) return;
+ const base = window.location.pathname.replace(/\/$/, "");
+ const sessionId = this.liveSplitSession.sessionId;
+
+ this.destroyHlsPlayers();
+ this.liveSplitSession = null;
+ this.expandedView = null;
+ this.render();
+
+ try {
+ await authFetch(`${base}/api/live-split/stop/${sessionId}`, { method: "POST" });
+ } catch { /* best effort */ }
+ }
+
+ private bind360LiveEvents() {
+ // Setup form inputs
+ const keyInput = this.shadow.querySelector('[data-input="live360-key"]') as HTMLInputElement;
+ if (keyInput) {
+ keyInput.addEventListener("input", () => { this.liveSplitStreamKey = keyInput.value; });
+ }
+
+ this.shadow.querySelectorAll("[data-live360-views]").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const n = parseInt((btn as HTMLElement).dataset.live360Views || "4");
+ this.liveSplitSettings.numViews = n;
+ this.liveSplitSettings.hFov = Math.round(360 / n);
+ this.render();
+ });
+ });
+
+ this.shadow.querySelectorAll("[data-live360-input]").forEach(input => {
+ input.addEventListener("change", () => {
+ const key = (input as HTMLElement).dataset.live360Input as keyof typeof this.liveSplitSettings;
+ const val = (input as HTMLInputElement).value;
+ if (key === "outputRes") {
+ this.liveSplitSettings[key] = val;
+ } else {
+ (this.liveSplitSettings as any)[key] = parseFloat(val) || 0;
+ }
+ });
+ });
+
+ // Start button
+ this.shadow.querySelector('[data-action="360live-start"]')?.addEventListener("click", () => {
+ if (!requireAuth("start 360 live split")) return;
+ this.startLiveSplit();
+ });
+
+ // Retry button
+ this.shadow.querySelector('[data-action="360live-retry"]')?.addEventListener("click", () => {
+ this.liveSplitError = "";
+ this.render();
+ });
+
+ // Stop button
+ this.shadow.querySelector('[data-action="360live-stop"]')?.addEventListener("click", () => {
+ this.stopLiveSplit();
+ });
+
+ // Click view cell to expand/collapse
+ this.shadow.querySelectorAll(".live360-cell").forEach(cell => {
+ cell.addEventListener("click", () => {
+ const idx = parseInt((cell as HTMLElement).dataset.viewIndex || "0");
+ this.expandedView = this.expandedView === idx ? null : idx;
+ // Re-render grid without destroying HLS players
+ const grid = this.shadow.querySelector(".live360-grid");
+ if (grid) {
+ grid.querySelectorAll(".live360-cell").forEach(c => {
+ const cIdx = parseInt((c as HTMLElement).dataset.viewIndex || "-1");
+ c.classList.toggle("expanded", cIdx === this.expandedView);
+ });
+ }
+ });
+ });
+ }
+
private bindEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
- this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";
+ const newMode = (btn as HTMLElement).dataset.mode as "library" | "live" | "360live";
+ if (!newMode) return;
+ if (this.mode === "360live" && newMode !== "360live") {
+ this.destroyHlsPlayers();
+ }
+ this.mode = newMode;
this.render();
});
});
diff --git a/modules/rtube/mod.ts b/modules/rtube/mod.ts
index 99ac48f..dcf10fc 100644
--- a/modules/rtube/mod.ts
+++ b/modules/rtube/mod.ts
@@ -306,6 +306,107 @@ routes.post("/api/360split/import/:jobId", async (c) => {
}
});
+// ── Live 360° Split routes ──
+
+// POST /api/live-split — start live splitting
+routes.post("/api/live-split", async (c) => {
+ const authToken = extractToken(c.req.raw.headers);
+ if (!authToken) return c.json({ error: "Authentication required" }, 401);
+ try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
+
+ if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
+
+ const { streamKey, numViews, hFov, vFov, overlap, outputRes } = await c.req.json();
+ if (!streamKey) return c.json({ error: "streamKey required" }, 400);
+
+ const streamUrl = `rtmp://rtube-rtmp:1935/live/${streamKey}`;
+
+ try {
+ const resp = await fetch(`${SPLIT_360_URL}/live-split`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ stream_url: streamUrl,
+ num_views: numViews || 4,
+ h_fov: hFov,
+ v_fov: vFov,
+ overlap: overlap || 0,
+ output_res: outputRes || "",
+ }),
+ });
+ if (!resp.ok) {
+ const text = await resp.text();
+ return c.json({ error: `live-split start failed: ${text}` }, 502);
+ }
+ return c.json(await resp.json());
+ } catch (e: any) {
+ console.error("[Tube] live-split start error:", e);
+ return c.json({ error: "Cannot reach 360split service" }, 502);
+ }
+});
+
+// GET /api/live-split/status/:sessionId — proxy status
+routes.get("/api/live-split/status/:sessionId", async (c) => {
+ if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
+ const sessionId = c.req.param("sessionId");
+ try {
+ const resp = await fetch(`${SPLIT_360_URL}/live-split/status/${sessionId}`);
+ if (!resp.ok) return c.json({ error: "Status check failed" }, resp.status as any);
+ return c.json(await resp.json());
+ } catch (e) {
+ console.error("[Tube] live-split status error:", e);
+ return c.json({ error: "Cannot reach 360split service" }, 502);
+ }
+});
+
+// POST /api/live-split/stop/:sessionId — stop session (auth-gated)
+routes.post("/api/live-split/stop/:sessionId", async (c) => {
+ const authToken = extractToken(c.req.raw.headers);
+ if (!authToken) return c.json({ error: "Authentication required" }, 401);
+ try { await verifyEncryptIDToken(authToken); } catch { return c.json({ error: "Invalid token" }, 401); }
+
+ if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
+ const sessionId = c.req.param("sessionId");
+ try {
+ const resp = await fetch(`${SPLIT_360_URL}/live-split/stop/${sessionId}`, { method: "POST" });
+ if (!resp.ok) return c.json({ error: "Stop failed" }, resp.status as any);
+ return c.json(await resp.json());
+ } catch (e) {
+ console.error("[Tube] live-split stop error:", e);
+ return c.json({ error: "Cannot reach 360split service" }, 502);
+ }
+});
+
+// GET /api/live-split/hls/:sessionId/* — proxy HLS segments
+routes.get("/api/live-split/hls/:sessionId/*", async (c) => {
+ if (!SPLIT_360_URL) return c.json({ error: "360split service not configured" }, 503);
+ const sessionId = c.req.param("sessionId");
+ const subpath = c.req.path.replace(new RegExp(`^.*/api/live-split/hls/${sessionId}/`), "");
+ if (!subpath) return c.json({ error: "Path required" }, 400);
+
+ try {
+ const resp = await fetch(`${SPLIT_360_URL}/live-split/hls/${sessionId}/${subpath}`);
+ if (!resp.ok) return new Response("Not found", { status: 404 });
+
+ const contentType = subpath.endsWith(".m3u8")
+ ? "application/vnd.apple.mpegurl"
+ : subpath.endsWith(".ts")
+ ? "video/mp2t"
+ : "application/octet-stream";
+
+ return new Response(resp.body, {
+ headers: {
+ "Content-Type": contentType,
+ "Access-Control-Allow-Origin": "*",
+ "Cache-Control": "no-cache, no-store",
+ },
+ });
+ } catch (e) {
+ console.error("[Tube] live-split HLS proxy error:", e);
+ return new Response("Proxy error", { status: 502 });
+ }
+});
+
// ── Page route ──
routes.get("/", (c) => {
const space = c.req.param("space") || "demo";