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

195 lines
8.6 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.
*/
class FolkVideoPlayer extends HTMLElement {
private shadow: ShadowRoot;
private space = "demo";
private videos: Array<{ name: string; size: number }> = [];
private currentVideo: string | null = null;
private mode: "library" | "live" = "library";
private streamKey = "";
constructor() {
super();
this.shadow = this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.space = this.getAttribute("space") || "demo";
this.loadVideos();
}
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: #e2e8f0; }
.container { max-width: 1200px; margin: 0 auto; }
.tabs { display: flex; gap: 0.5rem; margin-bottom: 1.5rem; }
.tab { padding: 0.5rem 1.25rem; border-radius: 8px; border: 1px solid #334155; background: transparent; color: #94a3b8; 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: rgba(15,23,42,0.5); border: 1px solid #1e293b; 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: rgba(51,65,85,0.5); }
.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: #64748b; 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: #64748b; }
.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 #334155; background: rgba(51,65,85,0.5); color: #e2e8f0; cursor: pointer; font-size: 0.8rem; text-decoration: none; }
.btn:hover { background: rgba(51,65,85,0.8); }
.live-section { max-width: 640px; margin: 0 auto; }
.live-card { background: rgba(15,23,42,0.5); border: 1px solid #1e293b; border-radius: 12px; padding: 2rem; }
input { width: 100%; padding: 0.75rem; background: rgba(0,0,0,0.3); border: 1px solid #334155; border-radius: 8px; color: white; 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: #334155; color: #64748b; 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: #64748b; }
.setup { margin-top: 1.5rem; padding: 1.5rem; background: rgba(15,23,42,0.3); border: 1px solid #1e293b; border-radius: 12px; }
.setup h3 { font-size: 0.875rem; color: #ef4444; margin-bottom: 0.5rem; }
.setup code { display: block; background: rgba(0,0,0,0.3); padding: 0.5rem 0.75rem; border-radius: 6px; font-size: 0.75rem; color: #94a3b8; margin-top: 0.5rem; overflow-x: auto; }
.setup ol { padding-left: 1.25rem; color: #94a3b8; font-size: 0.8rem; line-height: 1.8; }
@media (max-width: 768px) { .layout { grid-template-columns: 1fr; } }
</style>
<div class="container">
<div class="tabs">
<button class="tab ${this.mode === "library" ? "active" : ""}" data-mode="library">Video Library</button>
<button class="tab ${this.mode === "live" ? "active" : ""}" data-mode="live">Live Stream</button>
</div>
${this.mode === "library" ? this.renderLibrary() : this.renderLive()}
</div>
`;
this.bindEvents();
}
private renderLibrary(): string {
const sidebar = this.videos.length === 0
? `<div class="empty">No videos yet</div>`
: this.videos.map((v) => `
<div class="video-item ${this.currentVideo === v.name ? "active" : ""}" data-name="${v.name}">
<div class="video-name">${v.name}</div>
<div class="video-meta">${this.getExtension(v.name).toUpperCase()} &middot; ${this.formatSize(v.size)}</div>
</div>
`).join("");
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.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:#475569;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>
<div class="actions">
<button class="btn" data-action="copy">Copy Link</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">
<h2 style="font-size:1.25rem;margin-bottom:0.5rem">Watch a Live Stream</h2>
<p style="font-size:0.85rem;color:#94a3b8;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 bindEvents() {
this.shadow.querySelectorAll(".tab").forEach((btn) => {
btn.addEventListener("click", () => {
this.mode = (btn as HTMLElement).dataset.mode as "library" | "live";
this.render();
});
});
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);
}
});
}
}
}
customElements.define("folk-video-player", FolkVideoPlayer);