botserver/ui/suite/meet.html
Rodrigo Rodriguez (Pragmatismo) e68a12176d Add Suite app documentation, templates, and Askama config
- Add askama.toml for template configuration (ui/ directory)
- Add Suite app documentation with flow diagrams (SVG)
  - App launcher, chat flow, drive flow, tasks flow
  - Individual app docs: chat, drive, tasks, mail, etc.
- Add HTML templates for Suite apps
  - Base template with header and app launcher
  - Auth login page
  - Chat, Drive, Mail, Meet, Tasks templates
  - Partial templates for messages, sessions, notifications
- Add Extensions type to AppState for type-erased storage
- Add mTLS module for service-to-service authentication
- Update web handlers to use new template paths (suite/)
- Fix auth module to avoid axum-extra TypedHeader dependency
2025-11-30 21:00:48 -03:00

1071 lines
32 KiB
HTML

{% extends "base.html" %}
{% block title %}Meet - General Bots{% endblock %}
{% block content %}
<div class="meet-container" id="meet-app">
<!-- Pre-join Screen -->
<div class="prejoin-screen" id="prejoin-screen">
<div class="prejoin-card">
<div class="prejoin-header">
<h1>Join Meeting</h1>
<p>Configure your audio and video before joining</p>
</div>
<div class="preview-container">
<video id="local-preview" autoplay muted playsinline></video></video>
<div class="preview-placeholder" id="preview-placeholder">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/>
<circle cx="12" cy="7" r="4"/>
</svg>
<span>Camera is off</span>
</div>
<div class="preview-controls">
<button class="preview-btn" id="toggle-camera" title="Toggle camera">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 7l-7</span> 5 7 5V7z"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
</button>
<button class="preview-btn" id="toggle-mic" title="Toggle microphone">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
</button>
</div>
</div>
<div class="device-settings">
<div class="device-select">
<label>Camera</label>
<select id="camera-select">
<option value="">Select camera...</option>
</select>
</div>
<div class="device-select">
<label>Microphone</label>
<select id="mic-select">
<option value="">Select microphone...</option>
</select>
</div>
<div class="device-select">
<label>Speaker</label>
<select id="speaker-select">
<option value="">Select speaker...</option>
</select>
</div>
</div>
<div class="prejoin-actions">
<input type="text"
id="display-name"
placeholder="Your name"
value="{{ user_name|default("") }}"
class="name-input">
<button class="join-btn" id="join-btn"
hx-post="/api/meet/join"
hx-vals='js:{room: document.getElementById("room-id").value, name: document.getElementById("display-name").value}'
hx-target="#meet-app"
hx-swap="innerHTML">
Join Meeting
</button>
</div>
<input type="hidden" id="room-id" value="{{ room_id|default("") }}">
</div>
</div>
<!-- Meeting Room (hidden initially) -->
<div class="meeting-room" id="meeting-room" style="display: none;">
<!-- Meeting Header -->
<header class="meet-header">
<div class="meet-info">
<h2 id="meeting-title">{{ room_name|default("Meeting Room") }}</h2>
<span class="meeting-id" id="meeting-id-display">{{ room_id|default("") }}</span>
<span class="meeting-timer" id="meeting-timer">00:00:00</span>
</div>
<div class="meet-header-actions">
<button class="header-action-btn" id="record-btn" title="Record meeting">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4" fill="currentColor"/>
</svg>
<span>Record</span>
</button>
<button class="header-action-btn active" id="transcribe-btn" title="Toggle transcription">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
</svg>
<span>Transcribe</span>
</button>
<button class="header-action-btn" id="participants-btn" title="Show participants">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<span class="badge" id="participant-count">1</span>
</button>
<button class="header-action-btn" id="chat-btn" title="Toggle chat">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
<span class="badge hidden" id="unread-count">0</span>
</button>
</div>
</header>
<!-- Main Meeting Area -->
<main class="meet-main">
<!-- Video Grid -->
<div class="video-grid" id="video-grid">
<!-- Local Video -->
<div class="video-tile local" id="local-video-tile">
<video id="local-video" autoplay muted playsinline></video>
<div class="video-overlay">
<span class="participant-name">You</span>
<div class="video-indicators">
<span class="indicator mic" id="local-mic-indicator">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
</svg>
</span>
</div>
</div>
<div class="speaking-indicator"></div>
</div>
<!-- Remote participants added dynamically -->
<div id="remote-videos"></div>
</div>
<!-- Sidebar Panels -->
<aside class="meet-sidebar" id="meet-sidebar" style="display: none;">
<!-- Participants Panel -->
<div class="sidebar-panel" id="participants-panel" style="display: none;">
<div class="panel-header">
<h3>Participants</h3>
<button class="close-btn" onclick="togglePanel('participants')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="panel-content">
<div class="participants-list" id="participants-list">
<!-- Participants added dynamically -->
</div>
<div class="panel-actions">
<button class="action-btn" id="invite-btn"
hx-get="/api/meet/invite-modal"
hx-target="#modal-container">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="8.5" cy="7" r="4"/>
<line x1="20" y1="8" x2="20" y2="14"/>
<line x1="23" y1="11" x2="17" y2="11"/>
</svg>
Invite
</button>
<button class="action-btn" id="mute-all-btn"
hx-post="/api/meet/mute-all"
hx-swap="none">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"/>
</svg>
Mute All
</button>
</div>
</div>
</div>
<!-- Chat Panel -->
<div class="sidebar-panel" id="chat-panel" style="display: none;">
<div class="panel-header">
<h3>Chat</h3>
<button class="close-btn" onclick="togglePanel('chat')">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="panel-content">
<div class="chat-messages" id="chat-messages"
hx-ext="ws"
ws-connect="/ws/meet/{{ room_id }}/chat">
<!-- Chat messages added dynamically -->
</div>
<form class="chat-input-form" ws-send>
<input type="text"
name="message"
placeholder="Type a message..."
autocomplete="off">
<button type="submit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="22" y1="2" x2="11" y2="13"/>
<polygon points="22 2 15 22 11 13 2 9 22 2"/>
</svg>
</button>
</form>
</div>
</div>
</aside>
</main>
<!-- Meeting Controls -->
<footer class="meet-controls">
<div class="controls-left">
<span class="meeting-time" id="meeting-time-footer">00:00:00</span>
</div>
<div class="controls-center">
<button class="control-btn" id="mic-btn" title="Toggle microphone (M)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
<path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
<line x1="12" y1="19" x2="12" y2="23"/>
<line x1="8" y1="23" x2="16" y2="23"/>
</svg>
<span class="control-label">Mic</span>
</button>
<button class="control-btn" id="camera-btn" title="Toggle camera (V)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M23 7l-7 5 7 5V7z"/>
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"/>
</svg>
<span class="control-label">Camera</span>
</button>
<button class="control-btn" id="share-btn" title="Share screen (S)">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
<span class="control-label">Share</span>
</button>
<button class="control-btn" id="reactions-btn" title="Reactions">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M8 14s1.5 2 4 2 4-2 4-2"/>
<line x1="9" y1="9" x2="9.01" y2="9"/>
<line x1="15" y1="9" x2="15.01" y2="9"/>
</svg>
<span class="control-label">React</span>
</button>
<button class="control-btn danger" id="leave-btn" title="Leave meeting"
hx-post="/api/meet/leave"
hx-swap="none">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3"/>
<line x1="23" y1="1" x2="17" y2="7"/>
<line x1="17" y1="1" x2="23" y2="7"/>
</svg>
<span class="control-label">Leave</span>
</button>
</div>
<div class="controls-right">
<button class="control-btn-sm" id="settings-btn" title="Settings">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
</svg>
</button>
<button class="control-btn-sm" id="fullscreen-btn" title="Fullscreen (F)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"/>
</svg>
</button>
</div>
</footer>
</div>
<!-- Modal Container -->
<div id="modal-container"></div>
</div>
<style>
.meet-container {
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg);
}
/* Pre-join Screen */
.prejoin-screen {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 2rem;
}
.prejoin-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
padding: 2rem;
max-width: 500px;
width: 100%;
}
.prejoin-header {
text-align: center;
margin-bottom: 1.5rem;
}
.prejoin-header h1 {
font-size: 1.5rem;
margin-bottom: 0.5rem;
}
.prejoin-header p {
color: var(--text-secondary);
font-size: 0.875rem;
}
.preview-container {
position: relative;
aspect-ratio: 16/9;
background: #000;
border-radius: 12px;
overflow: hidden;
margin-bottom: 1.5rem;
}
.preview-container video {
width: 100%;
height: 100%;
object-fit: cover;
}
.preview-placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
gap: 0.5rem;
}
.preview-controls {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
}
.preview-btn {
padding: 0.75rem;
background: rgba(0, 0, 0, 0.6);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: background 0.2s;
}
.preview-btn:hover {
background: rgba(0, 0, 0, 0.8);
}
.preview-btn.off {
background: var(--error);
}
.device-settings {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.device-select label {
display: block;
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.device-select select {
width: 100%;
padding: 0.625rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.875rem;
}
.prejoin-actions {
display: flex;
gap: 0.75rem;
}
.name-input {
flex: 1;
padding: 0.75rem 1rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 1rem;
}
.join-btn {
padding: 0.75rem 1.5rem;
background: var(--primary);
color: white;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.join-btn:hover {
background: var(--primary-hover);
}
/* Meeting Room */
.meeting-room {
flex: 1;
display: flex;
flex-direction: column;
}
.meet-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.meet-info {
display: flex;
align-items: center;
gap: 1rem;
}
.meet-info h2 {
font-size: 1rem;
font-weight: 500;
}
.meeting-id {
font-size: 0.75rem;
color: var(--text-secondary);
padding: 0.25rem 0.5rem;
background: var(--bg);
border-radius: 4px;
}
.meeting-timer {
font-size: 0.875rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.meet-header-actions {
display: flex;
gap: 0.5rem;
}
.header-action-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-secondary);
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.header-action-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.header-action-btn.active {
background: var(--primary-light);
color: var(--primary);
}
.badge {
padding: 0.125rem 0.375rem;
background: var(--primary);
color: white;
border-radius: 10px;
font-size: 0.6875rem;
font-weight: 600;
}
.badge.hidden {
display: none;
}
/* Video Grid */
.meet-main {
flex: 1;
display: flex;
overflow: hidden;
}
.video-grid {
flex: 1;
display: grid;
gap: 0.5rem;
padding: 0.5rem;
background: #000;
}
.video-grid:has(.video-tile:nth-child(1):last-child) {
grid-template-columns: 1fr;
}
.video-grid:has(.video-tile:nth-child(2)) {
grid-template-columns: repeat(2, 1fr);
}
.video-grid:has(.video-tile:nth-child(3)),
.video-grid:has(.video-tile:nth-child(4)) {
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
}
.video-tile {
position: relative;
background: #1a1a1a;
border-radius: 8px;
overflow: hidden;
}
.video-tile video {
width: 100%;
height: 100%;
object-fit: cover;
}
.video-tile.local video {
transform: scaleX(-1);
}
.video-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 0.75rem;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
display: flex;
align-items: center;
justify-content: space-between;
}
.participant-name {
font-size: 0.875rem;
color: white;
font-weight: 500;
}
.video-indicators {
display: flex;
gap: 0.5rem;
}
.indicator {
padding: 0.375rem;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
color: white;
}
.indicator.muted {
color: var(--error);
}
.speaking-indicator {
position: absolute;
inset: 0;
border: 3px solid var(--success);
border-radius: 8px;
opacity: 0;
transition: opacity 0.2s;
pointer-events: none;
}
.video-tile.speaking .speaking-indicator {
opacity: 1;
}
/* Sidebar */
.meet-sidebar {
width: 320px;
background: var(--surface);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-panel {
flex: 1;
display: flex;
flex-direction: column;
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
border-bottom: 1px solid var(--border);
}
.panel-header h3 {
font-size: 1rem;
font-weight: 500;
}
.close-btn {
padding: 0.375rem;
background: transparent;
border: none;
color: var(--text-secondary);
border-radius: 4px;
cursor: pointer;
}
.close-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.panel-content {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.participants-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.participant-item {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
border-radius: 6px;
}
.participant-item:hover {
background: var(--surface-hover);
}
.participant-avatar {
width: 36px;
height: 36px;
background: var(--primary);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: 500;
font-size: 0.875rem;
}
.participant-info {
flex: 1;
}
.participant-info .name {
font-size: 0.875rem;
font-weight: 500;
}
.participant-info .role {
font-size: 0.75rem;
color: var(--text-secondary);
}
.panel-actions {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
}
.action-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 0.8125rem;
cursor: pointer;
}
.action-btn:hover {
background: var(--surface-hover);
}
/* Chat */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.chat-message {
max-width: 85%;
}
.chat-message.own {
align-self: flex-end;
}
.chat-message .sender {
font-size: 0.75rem;
color: var(--text-secondary);
margin-bottom: 0.25rem;
}
.chat-message .content {
padding: 0.625rem 0.875rem;
background: var(--bg);
border-radius: 12px;
font-size: 0.875rem;
}
.chat-message.own .content {
background: var(--primary);
color: white;
}
.chat-input-form {
display: flex;
gap: 0.5rem;
padding: 1rem;
border-top: 1px solid var(--border);
}
.chat-input-form input {
flex: 1;
padding: 0.625rem 0.875rem;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-size: 0.875rem;
}
.chat-input-form button {
padding: 0.625rem;
background: var(--primary);
border: none;
border-radius: 8px;
color: white;
cursor: pointer;
}
/* Controls */
.meet-controls {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem;
background: var(--surface);
border-top: 1px solid var(--border);
}
.controls-left,
.controls-right {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 150px;
}
.controls-right {
justify-content: flex-end;
}
.meeting-time {
font-size: 0.875rem;
color: var(--text-secondary);
font-variant-numeric: tabular-nums;
}
.controls-center {
display: flex;
align-items: center;
gap: 0.5rem;
}
.control-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 1.25rem;
background: var(--bg);
border: none;
border-radius: 12px;
color: var(--text);
cursor: pointer;
transition: background 0.2s;
}
.control-btn:hover {
background: var(--surface-hover);
}
.control-btn.off {
background: var(--error);
color: white;
}
.control-btn.danger {
background: var(--error);
color: white;
}
.control-btn.danger:hover {
background: #dc2626;
}
.control-label {
font-size: 0.6875rem;
font-weight: 500;
}
.control-btn-sm {
padding: 0.625rem;
background: var(--bg);
border: none;
border-radius: 8px;
color: var(--text-secondary);
cursor: pointer;
}
.control-btn-sm:hover {
background: var(--surface-hover);
color: var(--text);
}
@media (max-width: 768px) {
.meet-header-actions span:not(.badge) {
display: none;
}
.meet-sidebar {
position: fixed;
right: 0;
top: 0;
bottom: 0;
z-index: 100;
transform: translateX(100%);
transition: transform 0.3s;
}
.meet-sidebar.open {
transform: translateX(0);
}
.control-label {
display: none;
}
.control-btn {
padding: 0.75rem;
}
}
</style>
<script>
// Meeting state
const meetState = {
isMuted: false,
isCameraOff: false,
isSharing: false,
isRecording: false,
startTime: null,
timerInterval: null
};
// Pre-join device setup
async function initDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const cameraSelect = document.getElementById('camera-select');
const micSelect = document.getElementById('mic-select');
const speakerSelect = document.getElementById('speaker-select');
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.deviceId;
option.text = device.label || `${device.kind} ${device.deviceId.slice(0, 8)}`;
if (device.kind === 'videoinput') {
cameraSelect.appendChild(option);
} else if (device.kind === 'audioinput') {
micSelect.appendChild(option);
} else if (device.kind === 'audiooutput') {
speakerSelect.appendChild(option);
}
});
// Start preview
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('local-preview').srcObject = stream;
document.getElementById('preview-placeholder').style.display = 'none';
} catch (err) {
console.error('Error accessing devices:', err);
}
}
// Toggle panel
function togglePanel(panel) {
const sidebar = document.getElementById('meet-sidebar');
const participantsPanel = document.getElementById('participants-panel');
const chatPanel = document.getElementById('chat-panel');
if (panel === 'participants') {
if (participantsPanel.style.display === 'none') {
participantsPanel.style.display = 'flex';
chatPanel.style.display = 'none';
sidebar.style.display = 'flex';
} else {
participantsPanel.style.display = 'none';
sidebar.style.display = 'none';
}
} else if (panel === 'chat') {
if (chatPanel.style.display === 'none') {
chatPanel.style.display = 'flex';
participantsPanel.style.display = 'none';
sidebar.style.display = 'flex';
} else {
chatPanel.style.display = 'none';
sidebar.style.display = 'none';
}
}
}
// Timer
function startTimer() {
meetState.startTime = Date.now();
meetState.timerInterval = setInterval(updateTimer, 1000);
}
function updateTimer() {
const elapsed = Date.now() - meetState.startTime;
const hours = Math.floor(elapsed / 3600000);
const minutes = Math.floor((elapsed % 3600000) / 60000);
const seconds = Math.floor((elapsed % 60000) / 1000);
const timeStr = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
document.getElementById('meeting-timer').textContent = timeStr;
document.getElementById('meeting-time-footer').textContent = timeStr;
}
// Control buttons
document.getElementById('mic-btn')?.addEventListener('click', function() {
meetState.isMuted = !meetState.isMuted;
this.classList.toggle('off', meetState.isMuted);
});
document.getElementById('camera-btn')?.addEventListener('click', function() {
meetState.isCameraOff = !meetState.isCameraOff;
this.classList.toggle('off', meetState.isCameraOff);
});
document.getElementById('share-btn')?.addEventListener('click', async function() {
if (!meetState.isSharing) {
try {
const stream = await navigator.mediaDevices.getDisplayMedia({ video: true });
meetState.isSharing = true;
this.classList.add('active');
} catch (err) {
console.error('Screen share error:', err);
}
} else {
meetState.isSharing = false;
this.classList.remove('active');
}
});
document.getElementById('participants-btn')?.addEventListener('click', () => togglePanel('participants'));
document.getElementById('chat-btn')?.addEventListener('click', () => togglePanel('chat'));
document.getElementById('fullscreen-btn')?.addEventListener('click', () => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
switch (e.key.toLowerCase()) {
case 'm':
document.getElementById('mic-btn')?.click();
break;
case 'v':
document.getElementById('camera-btn')?.click();
break;
case 's':
document.getElementById('share-btn')?.click();
break;
case 'f':
document.getElementById('fullscreen-btn')?.click();
break;
}
});
// Initialize on load
document.addEventListener('DOMContentLoaded', initDevices);
</script>
{% endblock %}