rspace-online/modules/rtube/components/folk-video-player.ts

845 lines
36 KiB
TypeScript

/**
* folk-video-player — Video library browser + player.
*
* Lists videos from the API, plays them with native <video>,
* and provides HLS live stream viewing.
*/
import { TourEngine } from "../../../shared/tour-engine";
import { authFetch, requireAuth } from "../../../shared/auth-fetch";
class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private videos: Array<{ name: string; size: number; duration?: string; date?: string }> = [];
private currentVideo: string | null = null;
private mode: "library" | "live" | "360live" = "library";
private streamKey = "";
private searchTerm = "";
private isDemo = false;
private splitModalOpen = false;
private splitJob: { jobId: string; status: string; progress: number; currentView: string; outputFiles: string[]; error: string } | null = null;
private splitSettings = { numViews: 4, hFov: 90, vFov: 90, overlap: 0, outputRes: "" };
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 },
{ target: '[data-input="search"]', title: "Search Videos", message: "Filter videos by name to quickly find what you need.", advanceOnClick: false },
{ target: '.player-area', title: "Video Player", message: "Select a video from the sidebar to start playback.", advanceOnClick: false },
];
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
this._tour = new TourEngine(
this.shadow,
FolkVideoPlayer.TOUR_STEPS,
"rtube_tour_done",
() => this.shadow.host as HTMLElement,
);
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
if (this.space === "demo") { this.loadDemoData(); }
else { this.loadVideos(); }
if (!localStorage.getItem("rtube_tour_done")) {
setTimeout(() => this._tour.start(), 1200);
}
}
private loadDemoData() {
this.isDemo = true;
this.videos = [
{ name: "community-meeting-2026-02-15.mp4", size: 524288000, duration: "1:23:45", date: "Feb 15, 2026" },
{ name: "rspace-demo-walkthrough.mp4", size: 157286400, duration: "18:32", date: "Feb 12, 2026" },
{ name: "design-sprint-day1.webm", size: 892108800, duration: "2:45:10", date: "Feb 10, 2026" },
{ name: "interview-cosmolocal-founders.mp4", size: 1073741824, duration: "52:18", date: "Feb 7, 2026" },
{ name: "workshop-local-first-data.mp4", size: 734003200, duration: "1:35:42", date: "Feb 3, 2026" },
{ name: "lightning-talks-feb2026.webm", size: 445644800, duration: "42:15", date: "Jan 28, 2026" },
];
this.currentVideo = this.videos[0].name;
this.render();
}
private async loadVideos() {
try {
const base = window.location.pathname.replace(/\/$/, "");
const resp = await fetch(`${base}/api/videos`);
if (resp.ok) {
const data = await resp.json();
this.videos = data.videos || [];
}
} catch { /* ignore */ }
this.render();
}
private formatSize(bytes: number): string {
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
private getExtension(name: string): string {
return name.substring(name.lastIndexOf(".") + 1).toLowerCase();
}
private isPlayable(name: string): boolean {
const ext = this.getExtension(name);
return ["mp4", "webm", "m4v"].includes(ext);
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; min-height: 60vh; font-family: system-ui, sans-serif; color: var(--rs-text-primary); }
.container { max-width: 1200px; margin: 0 auto; }
.rapp-nav { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; min-height: 36px; }
.rapp-nav__title { font-size: 13px; font-weight: 500; color: var(--rs-text-muted); flex: 1; text-align: right; }
.tab { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.875rem; }
.tab.active { background: #ef4444; color: white; border-color: #ef4444; }
.layout { display: grid; grid-template-columns: 300px 1fr; gap: 1.5rem; }
.sidebar { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1rem; max-height: 70vh; overflow-y: auto; }
.video-item { padding: 0.75rem; border-radius: 8px; cursor: pointer; border: 1px solid transparent; margin-bottom: 0.25rem; }
.video-item:hover { background: var(--rs-bg-hover); }
.video-item.active { background: rgba(239,68,68,0.1); border-color: #ef4444; }
.video-name { font-size: 0.875rem; font-weight: 500; word-break: break-all; }
.video-meta { font-size: 0.75rem; color: var(--rs-text-muted); margin-top: 0.25rem; }
.player-area { background: #000; border-radius: 12px; overflow: hidden; aspect-ratio: 16/9; display: flex; align-items: center; justify-content: center; }
video { width: 100%; height: 100%; }
.placeholder { text-align: center; color: var(--rs-text-muted); }
.placeholder-icon { font-size: 3rem; margin-bottom: 1rem; }
.info-bar { margin-top: 1rem; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 0.75rem; }
.info-name { font-weight: 500; }
.actions { display: flex; gap: 0.5rem; }
.btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid var(--rs-border); background: var(--rs-btn-secondary-bg); color: var(--rs-text-primary); cursor: pointer; font-size: 0.8rem; text-decoration: none; }
.btn:hover { background: var(--rs-bg-surface-raised); }
.live-section { max-width: 640px; margin: 0 auto; }
.live-card { background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; padding: 2rem; }
input { width: 100%; padding: 0.75rem; background: var(--rs-input-bg); border: 1px solid var(--rs-input-border); border-radius: 8px; color: var(--rs-input-text); font-size: 0.875rem; margin-bottom: 1rem; box-sizing: border-box; }
.btn-primary { padding: 0.75rem 1.5rem; background: #ef4444; color: white; border: none; border-radius: 8px; cursor: pointer; font-weight: 500; width: 100%; font-size: 0.875rem; }
.btn-primary:disabled { background: var(--rs-bg-surface-raised); color: var(--rs-text-muted); cursor: not-allowed; }
.live-badge { display: inline-flex; align-items: center; gap: 0.5rem; }
.live-dot { width: 10px; height: 10px; background: #ef4444; border-radius: 50%; animation: pulse 2s infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
.empty { text-align: center; padding: 3rem; color: var(--rs-text-muted); }
.setup { margin-top: 1.5rem; padding: 1.5rem; background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 12px; }
.setup h3 { font-size: 0.875rem; color: #ef4444; margin-bottom: 0.5rem; }
.setup code { display: block; background: var(--rs-input-bg); padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.75rem; color: var(--rs-text-secondary); margin-top: 0.5rem; overflow-x: auto; }
.setup ol { padding-left: 1.25rem; color: var(--rs-text-secondary); font-size: 0.8rem; line-height: 1.8; }
.split-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; }
.split-modal { background: var(--rs-bg-surface, #1a1a2e); border: 1px solid var(--rs-border); border-radius: 12px; padding: 1.5rem; width: 420px; max-width: 90vw; max-height: 85vh; overflow-y: auto; }
.split-modal h3 { margin: 0 0 1rem; font-size: 1rem; }
.split-modal label { display: block; font-size: 0.8rem; color: var(--rs-text-secondary); margin-bottom: 0.25rem; }
.split-modal input[type="number"], .split-modal input[type="text"] { margin-bottom: 0.75rem; }
.view-btns { display: flex; gap: 0.5rem; margin-bottom: 1rem; }
.view-btn { flex: 1; padding: 0.5rem; border-radius: 8px; border: 1px solid var(--rs-border); background: transparent; color: var(--rs-text-secondary); cursor: pointer; font-size: 0.85rem; }
.view-btn.active { background: #ef4444; color: white; border-color: #ef4444; }
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.75rem; }
.split-progress { margin-top: 1rem; }
.progress-bar { height: 8px; background: var(--rs-bg-hover, #333); border-radius: 4px; overflow: hidden; margin: 0.5rem 0; }
.progress-fill { height: 100%; background: #ef4444; border-radius: 4px; transition: width 0.3s; }
.split-status { font-size: 0.8rem; color: var(--rs-text-secondary); }
.split-error { color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem; }
.split-results { margin-top: 1rem; }
.split-results li { font-size: 0.8rem; color: var(--rs-text-secondary); padding: 0.25rem 0; list-style: none; }
.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; }
.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.mode === "live" ? this.renderLive() : this.render360Live()}
</div>
${this.splitModalOpen ? this.renderSplitModal() : ""}
`;
this.bindEvents();
this.bindSplitEvents();
if (this.mode === "360live") this.bind360LiveEvents();
this._tour.renderOverlay();
}
startTour() {
this._tour.start();
}
private renderLibrary(): string {
const filteredVideos = this.searchTerm
? this.videos.filter(v => v.name.toLowerCase().includes(this.searchTerm.toLowerCase()))
: this.videos;
const searchInput = `<input type="text" placeholder="Search videos..." data-input="search" value="${this.searchTerm}" style="margin-bottom:0.75rem" />`;
const videoList = filteredVideos.length === 0
? `<div class="empty">${this.videos.length === 0 ? "No videos yet" : "No matches"}</div>`
: filteredVideos.map((v) => `
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}" data-collab-id="video:${v.name}">
<div class="video-name">${v.name}</div>
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} &middot; ${this.formatSize(v.size)}${v.duration ? ` &middot; ${v.duration}` : ""}${v.date ? `<br>${v.date}` : ""}</div>
</div>
`).join("");
const sidebar = searchInput + videoList;
let player: string;
if (!this.currentVideo) {
player = `<div class="placeholder"><div class="placeholder-icon">&#127916;</div><p>Select a video to play</p></div>`;
} else if (this.isDemo) {
const selectedVideo = this.videos.find(v => v.name === this.currentVideo);
const sizeStr = selectedVideo ? this.formatSize(selectedVideo.size) : "";
const durStr = selectedVideo?.duration || "";
player = `<div class="placeholder" style="padding:2rem">
<div style="width:80px;height:80px;border-radius:50%;background:rgba(239,68,68,0.15);display:flex;align-items:center;justify-content:center;margin:0 auto 1.5rem">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
</div>
<p style="font-size:1.1rem;font-weight:600;margin-bottom:0.5rem;color:var(--rs-text-primary)">${this.currentVideo}</p>
<p style="font-size:0.85rem;color:var(--rs-text-secondary)">${this.getExtension(this.currentVideo).toUpperCase()} &middot; ${sizeStr}${durStr ? ` &middot; ${durStr}` : ""}</p>
${selectedVideo?.date ? `<p style="font-size:0.8rem;color:var(--rs-text-muted);margin-top:0.5rem">Recorded ${selectedVideo.date}</p>` : ""}
<p style="font-size:0.7rem;color:var(--rs-text-secondary);margin-top:1.5rem;opacity:0.7">Demo preview &mdash; connect rTube to stream real video</p>
</div>`;
} else if (!this.isPlayable(this.currentVideo)) {
player = `<div class="placeholder"><div class="placeholder-icon">&#9888;&#65039;</div><p><strong>${this.getExtension(this.currentVideo).toUpperCase()}</strong> files cannot play in browsers</p><p style="font-size:0.8rem;color:var(--rs-text-secondary);margin-top:0.5rem">Download to play locally</p></div>`;
} else {
const base = window.location.pathname.replace(/\/$/, "");
player = `<video controls autoplay><source src="${base}/api/v/${encodeURIComponent(this.currentVideo)}" type="${this.getExtension(this.currentVideo) === "webm" ? "video/webm" : "video/mp4"}"></video>`;
}
const infoBar = this.currentVideo ? `
<div class="info-bar">
<span class="info-name">${this.currentVideo}</span>
${!this.isDemo ? `<div class="actions"><button class="btn" data-action="copy">Copy Link</button><button class="btn" data-action="split360">Split 360&deg;</button></div>` : ""}
</div>
` : "";
return `
<div class="layout">
<div class="sidebar">${sidebar}</div>
<div>
<div class="player-area">${player}</div>
${infoBar}
</div>
</div>
`;
}
private renderLive(): string {
return `
<div class="live-section">
<div class="live-card">
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:var(--rs-text-primary)">Watch a Live Stream</h3>
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1.5rem">Enter the stream key to watch a live broadcast.</p>
<input type="text" placeholder="Stream key (e.g. community-meeting)" data-input="streamkey" />
<button class="btn-primary" data-action="watch">Watch Stream</button>
</div>
<div class="setup">
<h3>Broadcaster Setup (OBS Studio)</h3>
<ol>
<li>Open Settings &rarr; Stream</li>
<li>Set Service to <strong>Custom</strong></li>
<li>Server: <code>rtmp://rtube.online:1936/live</code></li>
<li>Stream Key: any key (e.g. <code>community-meeting</code>)</li>
<li>Click <strong>Start Streaming</strong></li>
</ol>
</div>
</div>
`;
}
private render360Live(): string {
if (this.liveSplitSession && this.liveSplitSession.status === "running") {
return this.render360LiveRunning();
}
if (this.liveSplitError) {
return `
<div class="live-section">
<div class="live-card">
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:#ef4444">Error</h3>
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1rem">${this.liveSplitError}</p>
<button class="btn-primary" data-action="360live-retry">Try Again</button>
</div>
</div>
`;
}
// Setup form
const s = this.liveSplitSettings;
return `
<div class="live-section">
<div class="live-card">
<h3 style="font-size:1rem;margin:0 0 0.5rem;color:var(--rs-text-primary)">360&deg; Live Stream Splitter</h3>
<p style="font-size:0.85rem;color:var(--rs-text-secondary);margin-bottom:1.5rem">Split a live 360&deg; RTMP stream into multiple perspective views in real-time.</p>
<label style="font-size:0.8rem;color:var(--rs-text-secondary)">Stream Key</label>
<input type="text" placeholder="Stream key (e.g. test360)" data-input="live360-key" value="${this.liveSplitStreamKey}" />
<label style="font-size:0.8rem;color:var(--rs-text-secondary)">Number of Views</label>
<div class="view-btns" style="margin-bottom:1rem">
${[2, 3, 4, 6].map(n => `<button class="view-btn ${s.numViews === n ? "active" : ""}" data-live360-views="${n}">${n}</button>`).join("")}
</div>
<div class="settings-grid">
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">H-FOV (&deg;)</label><input type="number" data-live360-input="hFov" value="${s.hFov}" min="30" max="360" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">V-FOV (&deg;)</label><input type="number" data-live360-input="vFov" value="${s.vFov}" min="30" max="180" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">Overlap (&deg;)</label><input type="number" data-live360-input="overlap" value="${s.overlap}" min="0" max="90" /></div>
<div><label style="font-size:0.8rem;color:var(--rs-text-secondary)">Output Resolution</label><input type="text" data-live360-input="outputRes" value="${s.outputRes}" placeholder="e.g. 1280x720" /></div>
</div>
<button class="btn-primary" data-action="360live-start" ${this.liveSplitStarting ? "disabled" : ""}>${this.liveSplitStarting ? "Starting..." : "Start Live Split"}</button>
</div>
<div class="setup" style="margin-top:1.5rem">
<h3>Broadcaster Setup</h3>
<ol>
<li>Stream 360&deg; video to <code>rtmp://rtube.online:1936/live/{key}</code></li>
<li>Enter the same stream key above</li>
<li>Choose number of views and click Start</li>
<li>Each view shows a different perspective of the 360&deg; stream</li>
</ol>
</div>
</div>
`;
}
private render360LiveRunning(): string {
const session = this.liveSplitSession!;
const viewCount = session.views.length;
const gridClass = `views-${viewCount}`;
const cells = session.views.map(v => {
const isExpanded = this.expandedView === v.index;
return `
<div class="live360-cell ${isExpanded ? "expanded" : ""}" data-view-index="${v.index}">
<video id="live360-video-${v.index}" muted autoplay playsinline></video>
<div class="live360-label">
<span class="live-dot-sm"></span>
${v.label} (${Math.round(v.yaw)}&deg;)
</div>
</div>
`;
}).join("");
return `
<div class="live360-section">
<div class="live360-grid ${gridClass}">${cells}</div>
<div class="live360-controls">
<div class="live360-status">
<span class="live-badge"><span class="live-dot"></span> Live</span>
&nbsp; Session: ${session.sessionId} &middot; ${viewCount} views
</div>
<button class="btn" data-action="360live-stop">Stop</button>
</div>
</div>
`;
}
private async loadHlsJs(): Promise<any> {
if ((window as any).Hls) return (window as any).Hls;
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "https://cdn.jsdelivr.net/npm/hls.js@1.5.7/dist/hls.min.js";
script.onload = () => resolve((window as any).Hls);
script.onerror = () => reject(new Error("Failed to load HLS.js"));
document.head.appendChild(script);
});
}
private async attachHlsPlayers() {
if (!this.liveSplitSession) return;
const Hls = await this.loadHlsJs();
if (!Hls.isSupported()) {
this.liveSplitError = "HLS.js is not supported in this browser";
this.render();
return;
}
this.destroyHlsPlayers();
const base = window.location.pathname.replace(/\/$/, "");
for (const view of this.liveSplitSession.views) {
const video = this.shadow.getElementById(`live360-video-${view.index}`) as HTMLVideoElement;
if (!video) continue;
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
enableWorker: true,
});
const hlsUrl = `${base}/api/live-split/hls/${this.liveSplitSession.sessionId}/${view.hls_url.replace(/^.*?view_/, "view_")}`;
hls.loadSource(hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
this.hlsPlayers.push(hls);
}
}
private destroyHlsPlayers() {
for (const hls of this.hlsPlayers) {
try { hls.destroy(); } catch {}
}
this.hlsPlayers = [];
if (this.liveSplitStatusInterval) {
clearInterval(this.liveSplitStatusInterval);
this.liveSplitStatusInterval = null;
}
}
private async startLiveSplit() {
if (!this.liveSplitStreamKey) return;
const base = window.location.pathname.replace(/\/$/, "");
const s = this.liveSplitSettings;
this.liveSplitStarting = true;
this.liveSplitError = "";
this.render();
try {
const resp = await authFetch(`${base}/api/live-split`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
streamKey: this.liveSplitStreamKey,
numViews: s.numViews,
hFov: s.hFov,
vFov: s.vFov,
overlap: s.overlap || undefined,
outputRes: s.outputRes || undefined,
}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: "Request failed" }));
throw new Error(data.error || `HTTP ${resp.status}`);
}
const data = await resp.json();
this.liveSplitSession = {
sessionId: data.session_id,
status: data.status,
views: data.views.map((v: any) => ({
index: v.index,
label: v.label,
yaw: v.yaw,
hls_url: v.hls_url,
})),
};
this.liveSplitStarting = false;
this.render();
// Wait a moment for FFmpeg to produce initial segments
setTimeout(() => this.attachHlsPlayers(), 4000);
this.startLiveSplitStatusPoll();
} catch (e: any) {
this.liveSplitStarting = false;
this.liveSplitError = e.message || "Failed to start live split";
this.render();
}
}
private startLiveSplitStatusPoll() {
if (this.liveSplitStatusInterval) clearInterval(this.liveSplitStatusInterval);
const base = window.location.pathname.replace(/\/$/, "");
const sessionId = this.liveSplitSession?.sessionId;
if (!sessionId) return;
this.liveSplitStatusInterval = setInterval(async () => {
try {
const resp = await fetch(`${base}/api/live-split/status/${sessionId}`);
if (!resp.ok) return;
const data = await resp.json();
if (data.status === "error") {
this.liveSplitError = data.error || "Stream ended unexpectedly";
this.liveSplitSession = null;
this.destroyHlsPlayers();
this.render();
}
} catch { /* retry */ }
}, 5000);
}
private async stopLiveSplit() {
if (!this.liveSplitSession) return;
const base = window.location.pathname.replace(/\/$/, "");
const sessionId = this.liveSplitSession.sessionId;
this.destroyHlsPlayers();
this.liveSplitSession = null;
this.expandedView = null;
this.render();
try {
await authFetch(`${base}/api/live-split/stop/${sessionId}`, { method: "POST" });
} catch { /* best effort */ }
}
private bind360LiveEvents() {
// Setup form inputs
const keyInput = this.shadow.querySelector('[data-input="live360-key"]') as HTMLInputElement;
if (keyInput) {
keyInput.addEventListener("input", () => { this.liveSplitStreamKey = keyInput.value; });
}
this.shadow.querySelectorAll("[data-live360-views]").forEach(btn => {
btn.addEventListener("click", () => {
const n = parseInt((btn as HTMLElement).dataset.live360Views || "4");
this.liveSplitSettings.numViews = n;
this.liveSplitSettings.hFov = Math.round(360 / n);
this.render();
});
});
this.shadow.querySelectorAll("[data-live360-input]").forEach(input => {
input.addEventListener("change", () => {
const key = (input as HTMLElement).dataset.live360Input as keyof typeof this.liveSplitSettings;
const val = (input as HTMLInputElement).value;
if (key === "outputRes") {
this.liveSplitSettings[key] = val;
} else {
(this.liveSplitSettings as any)[key] = parseFloat(val) || 0;
}
});
});
// Start button
this.shadow.querySelector('[data-action="360live-start"]')?.addEventListener("click", () => {
if (!requireAuth("start 360 live split")) return;
this.startLiveSplit();
});
// Retry button
this.shadow.querySelector('[data-action="360live-retry"]')?.addEventListener("click", () => {
this.liveSplitError = "";
this.render();
});
// Stop button
this.shadow.querySelector('[data-action="360live-stop"]')?.addEventListener("click", () => {
this.stopLiveSplit();
});
// Click view cell to expand/collapse
this.shadow.querySelectorAll(".live360-cell").forEach(cell => {
cell.addEventListener("click", () => {
const idx = parseInt((cell as HTMLElement).dataset.viewIndex || "0");
this.expandedView = this.expandedView === idx ? null : idx;
// Re-render grid without destroying HLS players
const grid = this.shadow.querySelector(".live360-grid");
if (grid) {
grid.querySelectorAll(".live360-cell").forEach(c => {
const cIdx = parseInt((c as HTMLElement).dataset.viewIndex || "-1");
c.classList.toggle("expanded", cIdx === this.expandedView);
});
}
});
});
}
private bindEvents() {
this.shadow.querySelector("#btn-tour")?.addEventListener("click", () => this.startTour());
this.shadow.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
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();
});
});
const searchInput = this.shadow.querySelector('[data-input="search"]') as HTMLInputElement;
if (searchInput) {
searchInput.addEventListener("input", () => {
this.searchTerm = searchInput.value;
this.render();
// Restore focus and cursor position after re-render
const newInput = this.shadow.querySelector('[data-input="search"]') as HTMLInputElement;
if (newInput) { newInput.focus(); newInput.selectionStart = newInput.selectionEnd = newInput.value.length; }
});
}
this.shadow.querySelectorAll(".video-item").forEach((item) => {
item.addEventListener("click", () => {
this.currentVideo = (item as HTMLElement).dataset.name || null;
this.render();
});
});
const copyBtn = this.shadow.querySelector('[data-action="copy"]');
if (copyBtn) {
copyBtn.addEventListener("click", () => {
if (this.currentVideo) {
const base = window.location.pathname.replace(/\/$/, "");
const url = `${window.location.origin}${base}/api/v/${encodeURIComponent(this.currentVideo)}`;
navigator.clipboard.writeText(url);
}
});
}
const splitBtn = this.shadow.querySelector('[data-action="split360"]');
if (splitBtn) {
splitBtn.addEventListener("click", () => {
if (!requireAuth("split 360° video")) return;
this.splitJob = null;
this.splitImported = null;
this.splitImporting = false;
this.splitModalOpen = true;
this.render();
});
}
}
private renderSplitModal(): string {
const s = this.splitSettings;
const job = this.splitJob;
const isProcessing = job && (job.status === "queued" || job.status === "processing");
const isComplete = job?.status === "complete";
const isError = job?.status === "error";
let body: string;
if (this.splitImported) {
body = `
<p style="color:#22c55e;font-weight:500;margin-bottom:0.5rem">Imported ${this.splitImported.length} files to library</p>
<ul class="split-results" style="padding:0;margin:0">${this.splitImported.map(f => `<li>${f}</li>`).join("")}</ul>
<button class="btn-primary" data-split="close" style="margin-top:1rem">Done</button>
`;
} else if (isComplete) {
body = `
<p style="color:#22c55e;font-weight:500;margin-bottom:0.5rem">Split complete!</p>
<ul class="split-results" style="padding:0;margin:0">${(job!.outputFiles || []).map(f => `<li>${f}</li>`).join("")}</ul>
<button class="btn-success" data-split="import" ${this.splitImporting ? "disabled" : ""}>${this.splitImporting ? "Importing..." : "Import All to Library"}</button>
`;
} else if (isProcessing) {
body = `
<div class="split-progress">
<div class="split-status">Status: ${job!.status}${job!.currentView ? ` &mdash; ${job!.currentView}` : ""}</div>
<div class="progress-bar"><div class="progress-fill" style="width:${job!.progress || 0}%"></div></div>
<div class="split-status">${job!.progress || 0}% complete</div>
</div>
`;
} else if (isError) {
body = `
<div class="split-error">${job!.error || "Unknown error"}</div>
<button class="btn-primary" data-split="retry" style="margin-top:0.75rem">Retry</button>
`;
} else {
body = `
<label>Number of Views</label>
<div class="view-btns">
${[2, 3, 4, 6].map(n => `<button class="view-btn ${s.numViews === n ? "active" : ""}" data-views="${n}">${n}</button>`).join("")}
</div>
<div class="settings-grid">
<div><label>H-FOV (&deg;)</label><input type="number" data-split-input="hFov" value="${s.hFov}" min="30" max="360" /></div>
<div><label>V-FOV (&deg;)</label><input type="number" data-split-input="vFov" value="${s.vFov}" min="30" max="180" /></div>
<div><label>Overlap (&deg;)</label><input type="number" data-split-input="overlap" value="${s.overlap}" min="0" max="90" /></div>
<div><label>Output Resolution</label><input type="text" data-split-input="outputRes" value="${s.outputRes}" placeholder="e.g. 1920x1080" /></div>
</div>
<button class="btn-primary" data-split="start">Start Split</button>
`;
}
return `
<div class="split-overlay" data-split="overlay">
<div class="split-modal">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem">
<h3 style="margin:0">Split 360&deg; Video</h3>
<button class="btn" data-split="close" style="padding:0.25rem 0.6rem;font-size:0.9rem">&times;</button>
</div>
<p style="font-size:0.8rem;color:var(--rs-text-secondary);margin-bottom:1rem">${this.currentVideo}</p>
${body}
</div>
</div>
`;
}
private bindSplitEvents() {
if (!this.splitModalOpen) return;
// Close modal
this.shadow.querySelector('[data-split="overlay"]')?.addEventListener("click", (e) => {
if ((e.target as HTMLElement).dataset.split === "overlay") this.closeSplitModal();
});
this.shadow.querySelectorAll('[data-split="close"]').forEach(el =>
el.addEventListener("click", () => this.closeSplitModal())
);
// View count buttons
this.shadow.querySelectorAll(".view-btn").forEach(btn => {
btn.addEventListener("click", () => {
const n = parseInt((btn as HTMLElement).dataset.views || "4");
this.splitSettings.numViews = n;
this.splitSettings.hFov = Math.round(360 / n);
this.render();
});
});
// Settings inputs
this.shadow.querySelectorAll("[data-split-input]").forEach(input => {
input.addEventListener("change", () => {
const key = (input as HTMLElement).dataset.splitInput as keyof typeof this.splitSettings;
const val = (input as HTMLInputElement).value;
if (key === "outputRes") {
this.splitSettings[key] = val;
} else {
(this.splitSettings as any)[key] = parseFloat(val) || 0;
}
});
});
// Start
this.shadow.querySelector('[data-split="start"]')?.addEventListener("click", () => this.startSplitJob());
// Retry
this.shadow.querySelector('[data-split="retry"]')?.addEventListener("click", () => {
this.splitJob = null;
this.render();
});
// Import
this.shadow.querySelector('[data-split="import"]')?.addEventListener("click", () => this.importSplitResults());
}
private closeSplitModal() {
this.splitModalOpen = false;
if (this.splitPollInterval) {
clearInterval(this.splitPollInterval);
this.splitPollInterval = null;
}
this.render();
}
private async startSplitJob() {
if (!this.currentVideo) return;
const base = window.location.pathname.replace(/\/$/, "");
const s = this.splitSettings;
this.splitJob = { jobId: "", status: "queued", progress: 0, currentView: "", outputFiles: [], error: "" };
this.render();
try {
const resp = await authFetch(`${base}/api/360split`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
videoName: this.currentVideo,
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" }));
this.splitJob = { jobId: "", status: "error", progress: 0, currentView: "", outputFiles: [], error: data.error || `HTTP ${resp.status}` };
this.render();
return;
}
const { jobId } = await resp.json();
this.splitJob.jobId = jobId;
this.splitJob.status = "queued";
this.render();
this.pollSplitStatus();
} catch (e: any) {
this.splitJob = { jobId: "", status: "error", progress: 0, currentView: "", outputFiles: [], error: e.message || "Network error" };
this.render();
}
}
private pollSplitStatus() {
if (this.splitPollInterval) clearInterval(this.splitPollInterval);
const base = window.location.pathname.replace(/\/$/, "");
const jobId = this.splitJob?.jobId;
if (!jobId) return;
this.splitPollInterval = setInterval(async () => {
try {
const resp = await fetch(`${base}/api/360split/status/${jobId}`);
if (!resp.ok) return;
const data = await resp.json();
this.splitJob = {
jobId,
status: data.status,
progress: data.progress || 0,
currentView: data.current_view || "",
outputFiles: data.output_files || [],
error: data.error || "",
};
this.render();
if (data.status === "complete" || data.status === "error") {
clearInterval(this.splitPollInterval!);
this.splitPollInterval = null;
}
} catch { /* retry on next poll */ }
}, 2000);
}
private async importSplitResults() {
if (!this.splitJob?.jobId) return;
this.splitImporting = true;
this.render();
const base = window.location.pathname.replace(/\/$/, "");
try {
const resp = await authFetch(`${base}/api/360split/import/${this.splitJob.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ originalName: this.currentVideo }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({ error: "Import failed" }));
this.splitJob.status = "error";
this.splitJob.error = data.error || "Import failed";
this.splitImporting = false;
this.render();
return;
}
const { imported } = await resp.json();
this.splitImported = imported;
this.splitImporting = false;
this.render();
// Refresh video list to show imported files
this.loadVideos();
} catch (e: any) {
this.splitJob!.status = "error";
this.splitJob!.error = e.message || "Import failed";
this.splitImporting = false;
this.render();
}
}
}
customElements.define("folk-video-player", FolkVideoPlayer);