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) => {
|
routes.get("/room/:room", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
const room = c.req.param("room");
|
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({
|
return c.html(renderExternalAppShell({
|
||||||
title: `${room} — rMeets | rSpace`,
|
title: `${room} — rMeets | rSpace`,
|
||||||
moduleId: "rmeets",
|
moduleId: "rmeets",
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
private space = "demo";
|
private space = "demo";
|
||||||
private videos: Array<{ name: string; size: number; duration?: string; date?: string }> = [];
|
private videos: Array<{ name: string; size: number; duration?: string; date?: string }> = [];
|
||||||
private currentVideo: string | null = null;
|
private currentVideo: string | null = null;
|
||||||
private mode: "library" | "live" = "library";
|
private mode: "library" | "live" | "360live" = "library";
|
||||||
private streamKey = "";
|
private streamKey = "";
|
||||||
private searchTerm = "";
|
private searchTerm = "";
|
||||||
private isDemo = false;
|
private isDemo = false;
|
||||||
|
|
@ -23,6 +23,15 @@ class FolkVideoPlayer extends HTMLElement {
|
||||||
private splitPollInterval: ReturnType<typeof setInterval> | null = null;
|
private splitPollInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
private splitImporting = false;
|
private splitImporting = false;
|
||||||
private splitImported: string[] | null = null;
|
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 _tour!: TourEngine;
|
||||||
private static readonly TOUR_STEPS = [
|
private static readonly TOUR_STEPS = [
|
||||||
{ target: '[data-mode="library"]', title: "Video Library", message: "Browse your recorded videos — search, select, and play.", advanceOnClick: false },
|
{ 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; }
|
.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 { 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; }
|
.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>
|
</style>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="rapp-nav">
|
<div class="rapp-nav">
|
||||||
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
|
<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 === "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>
|
<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>
|
<button class="tab" id="btn-tour" style="font-size:0.78rem;padding:4px 10px;opacity:0.7">Tour</button>
|
||||||
</div>
|
</div>
|
||||||
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
|
${this.mode === "library" ? this.renderLibrary() : this.mode === "live" ? this.renderLive() : this.render360Live()}
|
||||||
</div>
|
</div>
|
||||||
${this.splitModalOpen ? this.renderSplitModal() : ""}
|
${this.splitModalOpen ? this.renderSplitModal() : ""}
|
||||||
`;
|
`;
|
||||||
this.bindEvents();
|
this.bindEvents();
|
||||||
this.bindSplitEvents();
|
this.bindSplitEvents();
|
||||||
|
if (this.mode === "360live") this.bind360LiveEvents();
|
||||||
this._tour.renderOverlay();
|
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() {
|
private bindEvents() {
|
||||||
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
|
||||||
|
|
||||||
this.shadow.querySelectorAll(".tab").forEach((btn) => {
|
this.shadow.querySelectorAll(".tab").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
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();
|
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 ──
|
// ── Page route ──
|
||||||
routes.get("/", (c) => {
|
routes.get("/", (c) => {
|
||||||
const space = c.req.param("space") || "demo";
|
const space = c.req.param("space") || "demo";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue