rspace-online/lib/folk-video-chat.ts

537 lines
12 KiB
TypeScript

import { FolkShape } from "./folk-shape";
import { css, html } from "./tags";
const styles = css`
:host {
background: #1e1e1e;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3);
min-width: 400px;
min-height: 350px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: linear-gradient(135deg, #059669, #10b981);
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions {
display: flex;
gap: 4px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
display: flex;
flex-direction: column;
height: calc(100% - 36px);
background: #1e1e1e;
border-radius: 0 0 8px 8px;
}
.video-container {
flex: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
padding: 12px;
background: #0a0a0a;
}
.video-slot {
background: #2d2d2d;
border-radius: 8px;
aspect-ratio: 16/9;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
.video-slot video {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
.video-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #6b7280;
gap: 8px;
}
.video-placeholder-icon {
font-size: 32px;
opacity: 0.5;
}
.participant-name {
position: absolute;
bottom: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
}
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 12px;
background: #1e1e1e;
border-top: 1px solid #2d2d2d;
}
.control-btn {
width: 48px;
height: 48px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
transition: all 0.2s;
}
.control-btn.primary {
background: #10b981;
color: white;
}
.control-btn.primary:hover {
background: #059669;
}
.control-btn.secondary {
background: #374151;
color: white;
}
.control-btn.secondary:hover {
background: #4b5563;
}
.control-btn.danger {
background: #ef4444;
color: white;
}
.control-btn.danger:hover {
background: #dc2626;
}
.control-btn.muted {
background: #ef4444 !important;
}
.join-screen {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
padding: 24px;
}
.join-icon {
font-size: 48px;
}
.join-title {
color: white;
font-size: 18px;
font-weight: 600;
}
.join-subtitle {
color: #9ca3af;
font-size: 13px;
text-align: center;
}
.room-input {
padding: 10px 16px;
border: 2px solid #374151;
border-radius: 8px;
background: #2d2d2d;
color: white;
font-size: 14px;
width: 200px;
text-align: center;
outline: none;
}
.room-input:focus {
border-color: #10b981;
}
.join-btn {
padding: 12px 32px;
background: #10b981;
color: white;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.join-btn:hover {
background: #059669;
}
.join-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #0a0a0a;
font-size: 11px;
color: #9ca3af;
}
.recording-indicator {
display: flex;
align-items: center;
gap: 4px;
color: #ef4444;
}
.recording-dot {
width: 8px;
height: 8px;
background: #ef4444;
border-radius: 50%;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`;
interface Participant {
id: string;
name: string;
videoEnabled: boolean;
audioEnabled: boolean;
}
declare global {
interface HTMLElementTagNameMap {
"folk-video-chat": FolkVideoChat;
}
}
export class FolkVideoChat extends FolkShape {
static override tagName = "folk-video-chat";
static {
const sheet = new CSSStyleSheet();
const parentRules = Array.from(FolkShape.styles.cssRules)
.map((r) => r.cssText)
.join("\n");
const childRules = Array.from(styles.cssRules)
.map((r) => r.cssText)
.join("\n");
sheet.replaceSync(`${parentRules}\n${childRules}`);
this.styles = sheet;
}
#roomId: string | null = null;
#isJoined = false;
#isMuted = false;
#isVideoOff = false;
#isRecording = false;
#participants: Participant[] = [];
#localStream: MediaStream | null = null;
get roomId() {
return this.#roomId;
}
set roomId(value: string | null) {
this.#roomId = value;
}
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement("div");
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>\u{1F4F9}</span>
<span>Video Chat</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">\u00D7</button>
</div>
</div>
<div class="content">
<div class="join-screen">
<span class="join-icon">\u{1F4F9}</span>
<span class="join-title">Join Video Call</span>
<span class="join-subtitle">Enter a room name to start or join a call</span>
<input type="text" class="room-input" placeholder="Room name..." />
<button class="join-btn">Join Call</button>
</div>
</div>
`;
// Replace the container div (slot's parent) with our wrapper
const slot = root.querySelector("slot");
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) {
containerDiv.replaceWith(wrapper);
}
const content = wrapper.querySelector(".content") as HTMLElement;
const joinScreen = wrapper.querySelector(".join-screen") as HTMLElement;
const roomInput = wrapper.querySelector(".room-input") as HTMLInputElement;
const joinBtn = wrapper.querySelector(".join-btn") as HTMLButtonElement;
const closeBtn = wrapper.querySelector(".close-btn") as HTMLButtonElement;
// Join button handler
joinBtn.addEventListener("click", (e) => {
e.stopPropagation();
const roomName = roomInput.value.trim();
if (roomName) {
this.#roomId = roomName;
this.#joinCall(content, joinScreen);
}
});
roomInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") {
e.preventDefault();
joinBtn.click();
}
});
// Prevent drag on input
roomInput.addEventListener("pointerdown", (e) => e.stopPropagation());
// Close button
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#leaveCall();
this.dispatchEvent(new CustomEvent("close"));
});
return root;
}
async #joinCall(content: HTMLElement, joinScreen: HTMLElement) {
try {
// Request camera/microphone access
this.#localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true,
});
this.#isJoined = true;
// Add self as participant
this.#participants = [
{
id: "local",
name: "You",
videoEnabled: true,
audioEnabled: true,
},
];
// Replace join screen with video UI
content.innerHTML = `
<div class="status-bar">
<span>Room: ${this.#escapeHtml(this.#roomId || "")}</span>
<span>${this.#participants.length} participant(s)</span>
${this.#isRecording ? '<span class="recording-indicator"><span class="recording-dot"></span>Recording</span>' : ""}
</div>
<div class="video-container">
<div class="video-slot" id="local-video">
<video autoplay muted playsinline></video>
<span class="participant-name">You</span>
</div>
</div>
<div class="controls">
<button class="control-btn secondary" id="mute-btn" title="Mute">\u{1F50A}</button>
<button class="control-btn secondary" id="video-btn" title="Toggle Video">\u{1F4F7}</button>
<button class="control-btn secondary" id="record-btn" title="Record">\u{23FA}</button>
<button class="control-btn danger" id="leave-btn" title="Leave Call">\u{1F4DE}</button>
</div>
`;
// Attach local video stream
const localVideo = content.querySelector("#local-video video") as HTMLVideoElement;
if (localVideo && this.#localStream) {
localVideo.srcObject = this.#localStream;
}
// Control handlers
const muteBtn = content.querySelector("#mute-btn") as HTMLButtonElement;
const videoBtn = content.querySelector("#video-btn") as HTMLButtonElement;
const recordBtn = content.querySelector("#record-btn") as HTMLButtonElement;
const leaveBtn = content.querySelector("#leave-btn") as HTMLButtonElement;
muteBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#toggleMute(muteBtn);
});
videoBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#toggleVideo(videoBtn, localVideo);
});
recordBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#toggleRecording(recordBtn, content);
});
leaveBtn.addEventListener("click", (e) => {
e.stopPropagation();
this.#leaveCall();
// Reset to join screen
content.innerHTML = "";
content.appendChild(joinScreen);
});
this.dispatchEvent(
new CustomEvent("call-joined", { detail: { roomId: this.#roomId } })
);
} catch (error) {
console.error("Failed to join call:", error);
// Show error in join screen
const errorEl = document.createElement("div");
errorEl.style.cssText = "color: #ef4444; font-size: 12px; margin-top: 8px;";
errorEl.textContent = "Failed to access camera/microphone";
joinScreen.appendChild(errorEl);
}
}
#toggleMute(btn: HTMLButtonElement) {
this.#isMuted = !this.#isMuted;
if (this.#localStream) {
this.#localStream.getAudioTracks().forEach((track) => {
track.enabled = !this.#isMuted;
});
}
btn.textContent = this.#isMuted ? "\u{1F507}" : "\u{1F50A}";
btn.classList.toggle("muted", this.#isMuted);
}
#toggleVideo(btn: HTMLButtonElement, video: HTMLVideoElement) {
this.#isVideoOff = !this.#isVideoOff;
if (this.#localStream) {
this.#localStream.getVideoTracks().forEach((track) => {
track.enabled = !this.#isVideoOff;
});
}
btn.textContent = this.#isVideoOff ? "\u{1F4F7}\u{FE0F}\u{20E0}" : "\u{1F4F7}";
btn.classList.toggle("muted", this.#isVideoOff);
video.style.opacity = this.#isVideoOff ? "0.3" : "1";
}
#toggleRecording(btn: HTMLButtonElement, content: HTMLElement) {
this.#isRecording = !this.#isRecording;
btn.classList.toggle("muted", this.#isRecording);
const statusBar = content.querySelector(".status-bar");
if (statusBar) {
const existing = statusBar.querySelector(".recording-indicator");
if (this.#isRecording && !existing) {
const indicator = document.createElement("span");
indicator.className = "recording-indicator";
indicator.innerHTML =
'<span class="recording-dot"></span>Recording';
statusBar.appendChild(indicator);
} else if (!this.#isRecording && existing) {
existing.remove();
}
}
this.dispatchEvent(
new CustomEvent("recording-change", { detail: { isRecording: this.#isRecording } })
);
}
#leaveCall() {
if (this.#localStream) {
this.#localStream.getTracks().forEach((track) => track.stop());
this.#localStream = null;
}
this.#isJoined = false;
this.#isMuted = false;
this.#isVideoOff = false;
this.#isRecording = false;
this.#participants = [];
this.dispatchEvent(
new CustomEvent("call-left", { detail: { roomId: this.#roomId } })
);
}
#escapeHtml(text: string): string {
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
override toJSON() {
return {
...super.toJSON(),
type: "folk-video-chat",
roomId: this.roomId,
isJoined: this.#isJoined,
};
}
}