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:
parent
67eef28f68
commit
8ea2bb871b
|
|
@ -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)}°)</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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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° Live Stream Splitter</h3>
|
||||
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1.5rem">Split a live 360° 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 (°)</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 (°)</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 (°)</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° 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° 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)}°)
|
||||
</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>
|
||||
Session: ${session.sessionId} · ${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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
Loading…
Reference in New Issue