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

537 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>📹</span>
<span>Video Chat</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content">
<div class="join-screen">
<span class="join-icon">📹</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">🔊</button>
<button class="control-btn secondary" id="video-btn" title="Toggle Video">📷</button>
<button class="control-btn secondary" id="record-btn" title="Record">⏺</button>
<button class="control-btn danger" id="leave-btn" title="Leave Call">📞</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 ? "🔇" : "🔊";
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 ? "📷️⃠" : "📷";
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,
};
}
}