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 `
${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); 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

+
    +
  1. Stream 360° video to rtmp://rtube.online:1936/live/{key}
  2. +
  3. Enter the same stream key above
  4. +
  5. Choose number of views and click Start
  6. +
  7. Each view shows a different perspective of the 360° stream
  8. +
+
+
+ `; + } + + 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";