537 lines
12 KiB
TypeScript
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>📹</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,
|
||
};
|
||
}
|
||
}
|