feat(rtube,rmeets): live 360° stream splitting + Jitsi External API

- rtube: 4 live-split proxy routes (start/status/stop/hls), new "360 Live"
  mode in folk-video-player with HLS.js multi-view grid player
- rmeets: ?api=1 route for Jitsi External API mode, new folk-jitsi-room
  web component with 360° Director panel (canvas captureStream)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-16 14:28:13 -07:00
parent 67eef28f68
commit 8ea2bb871b
4 changed files with 817 additions and 4 deletions

View File

@ -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 = `
<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)}&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);

View File

@ -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: `<folk-jitsi-room room="${escapeHtml(room)}" jitsi-url="${escapeHtml(JITSI_URL)}" space="${escapeHtml(space)}"${director ? ` director="1" session="${escapeHtml(sessionId)}"` : ""}></folk-jitsi-room>`,
scripts: `<script type="module" src="/modules/rmeets/components/folk-jitsi-room.js"></script>`,
}));
}
return c.html(renderExternalAppShell({
title: `${room} — rMeets | rSpace`,
moduleId: "rmeets",

View File

@ -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<typeof setInterval> | 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<typeof setInterval> | 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; } }
</style>
<div class="container">
<div class="rapp-nav">
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
${!this.isDemo ? `<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>` : ""}
${!this.isDemo ? `<button class="tab ${this.mode === "360live" ? "active" : ""}" data-mode="360live">360 Live</button>` : ""}
<span class="rapp-nav__title">${this.isDemo ? `${this.videos.length} recordings` : ""}</span>
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
</div>
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
${this.mode === "library" ? this.renderLibrary() : this.mode === "live" ? this.renderLive() : this.render360Live()}
</div>
${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 `
<div class="live-section">
<div class="live-card">
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:#ef4444">Error</h3>
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1rem">${this.liveSplitError}</p>
<button class="btn-primary" data-action="360live-retry">Try Again</button>
</div>
</div>
`;
}
// Setup form
const s = this.liveSplitSettings;
return `
<div class="live-section">
<div class="live-card">
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:var(--rs-text-primary)">360&deg; Live Stream Splitter</h3>
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1.5rem">Split a live 360&deg; RTMP stream into multiple perspective views in real-time.</p>
<label style="font-size:0.8rem;color:var(--rs-text-secondary)">Stream Key</label>
<input type="text" placeholder="Stream key (e.g. test360)" data-input="live360-key" value="${this.liveSplitStreamKey}" />
<label style="font-size:0.8rem;color:var(--rs-text-secondary)">Number of Views</label>
<div class="view-btns" style="margin-bottom:1rem">
${[2, 3, 4, 6].map(n => `<button class="view-btn ${s.numViews === n ? "active" : ""}" data-live360-views="${n}">${n}</button>`).join("")}
</div>
<div class="settings-grid">
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">H-FOV (&deg;)</label><input type="number" data-live360-input="hFov" value="${s.hFov}" min="30" max="360" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">V-FOV (&deg;)</label><input type="number" data-live360-input="vFov" value="${s.vFov}" min="30" max="180" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">Overlap (&deg;)</label><input type="number" data-live360-input="overlap" value="${s.overlap}" min="0" max="90" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">Output Resolution</label><input type="text" data-live360-input="outputRes" value="${s.outputRes}" placeholder="e.g. 1280x720" /></div>
</div>
<button class="btn-primary" data-action="360live-start" ${this.liveSplitStarting ? "disabled" : ""}>${this.liveSplitStarting ? "Starting..." : "Start Live Split"}</button>
</div>
<div class="setup" style="margin-top:1.5rem">
<h3>Broadcaster Setup</h3>
<ol>
<li>Stream 360&deg; video to <code>rtmp://rtube.online:1936/live/{key}</code></li>
<li>Enter the same stream key above</li>
<li>Choose number of views and click Start</li>
<li>Each view shows a different perspective of the 360&deg; stream</li>
</ol>
</div>
</div>
`;
}
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 `
<div class="live360-cell ${isExpanded ? "expanded" : ""}" data-view-index="${v.index}">
<video id="live360-video-${v.index}" muted autoplay playsinline></video>
<div class="live360-label">
<span class="live-dot-sm"></span>
${v.label} (${Math.round(v.yaw)}&deg;)
</div>
</div>
`;
}).join("");
return `
<div class="live360-section">
<div class="live360-grid ${gridClass}">${cells}</div>
<div class="live360-controls">
<div class="live360-status">
<span class="live-badge"><span class="live-dot"></span> Live</span>
&nbsp; Session: ${session.sessionId} &middot; ${viewCount} views
</div>
<button class="btn" data-action="360live-stop">Stop</button>
</div>
</div>
`;
}
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 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();
});
});

View File

@ -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";