195 lines
8.6 KiB
TypeScript
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()} · ${this.formatSize(v.size)}</div>
|
|
</div>
|
|
`).join("");
|
|
|
|
let player: string;
|
|
if (!this.currentVideo) {
|
|
player = `<div class="placeholder"><div class="placeholder-icon">🎬</div><p>Select a video to play</p></div>`;
|
|
} else if (!this.isPlayable(this.currentVideo)) {
|
|
player = `<div class="placeholder"><div class="placeholder-icon">⚠️</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 → 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);
|