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>\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,
|
|
};
|
|
}
|
|
}
|