diff --git a/ts_web/elements/wcc-properties.ts b/ts_web/elements/wcc-properties.ts
index 2f2b3c1..aa116f8 100644
--- a/ts_web/elements/wcc-properties.ts
+++ b/ts_web/elements/wcc-properties.ts
@@ -48,6 +48,43 @@ export class WccProperties extends DeesElement {
editorError: string;
}> = [];
+ // Recording state properties
+ @state()
+ accessor recordingState: 'idle' | 'options' | 'recording' | 'preview' = 'idle';
+
+ @state()
+ accessor recordingMode: 'viewport' | 'screen' = 'screen';
+
+ @state()
+ accessor audioEnabled: boolean = false;
+
+ @state()
+ accessor selectedMicrophoneId: string = '';
+
+ @state()
+ accessor availableMicrophones: MediaDeviceInfo[] = [];
+
+ @state()
+ accessor audioLevel: number = 0;
+
+ @state()
+ accessor recordingDuration: number = 0;
+
+ @state()
+ accessor recordedBlob: Blob | null = null;
+
+ @state()
+ accessor previewVideoUrl: string = '';
+
+ // Recording private members
+ private mediaRecorder: MediaRecorder | null = null;
+ private recordedChunks: Blob[] = [];
+ private durationInterval: number | null = null;
+ private audioContext: AudioContext | null = null;
+ private audioAnalyser: AnalyserNode | null = null;
+ private audioMonitoringInterval: number | null = null;
+ private currentStream: MediaStream | null = null;
+
public editorHeight: number = 300;
public render(): TemplateResult {
@@ -88,7 +125,7 @@ export class WccProperties extends DeesElement {
}
.grid {
display: grid;
- grid-template-columns: 1fr 150px 300px 70px;
+ grid-template-columns: 1fr 150px 300px 70px 70px;
height: 100%;
}
.properties {
@@ -513,6 +550,360 @@ export class WccProperties extends DeesElement {
bottom: 0;
height: 100px;
}
+
+ /* Recording styles */
+ .recordingButton {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: transparent;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ color: #666;
+ border-left: 1px solid var(--border);
+ }
+
+ .recordingButton:hover {
+ background: rgba(239, 68, 68, 0.05);
+ color: #f87171;
+ }
+
+ .recordingButton.recording {
+ background: rgba(239, 68, 68, 0.15);
+ color: #f87171;
+ }
+
+ .recordingButton .rec-icon {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: currentColor;
+ }
+
+ .recordingButton.recording .rec-icon {
+ animation: pulse-recording 1s ease-in-out infinite;
+ }
+
+ @keyframes pulse-recording {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.5; transform: scale(0.9); }
+ }
+
+ .recording-timer {
+ font-family: 'Consolas', 'Monaco', monospace;
+ font-size: 0.7rem;
+ margin-left: 0.25rem;
+ }
+
+ /* Recording Options Panel */
+ .recording-options-panel {
+ position: fixed;
+ right: 16px;
+ bottom: 116px;
+ width: 360px;
+ background: #0c0c0c;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: var(--radius-md);
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
+ z-index: 1000;
+ overflow: hidden;
+ }
+
+ .recording-options-header {
+ padding: 0.75rem 1rem;
+ background: rgba(255, 255, 255, 0.02);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .recording-options-title {
+ font-size: 0.8rem;
+ font-weight: 500;
+ color: #ccc;
+ }
+
+ .recording-options-close {
+ width: 24px;
+ height: 24px;
+ background: transparent;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ transition: all 0.15s ease;
+ }
+
+ .recording-options-close:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: #999;
+ }
+
+ .recording-options-content {
+ padding: 1rem;
+ }
+
+ .recording-option-group {
+ margin-bottom: 1rem;
+ }
+
+ .recording-option-group:last-child {
+ margin-bottom: 0;
+ }
+
+ .recording-option-label {
+ font-size: 0.7rem;
+ font-weight: 500;
+ color: #888;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ margin-bottom: 0.5rem;
+ }
+
+ .recording-mode-buttons {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .recording-mode-btn {
+ flex: 1;
+ padding: 0.6rem 0.75rem;
+ background: var(--input);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ color: #999;
+ font-size: 0.75rem;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-align: center;
+ }
+
+ .recording-mode-btn:hover {
+ border-color: var(--primary);
+ color: #ccc;
+ }
+
+ .recording-mode-btn.selected {
+ background: rgba(59, 130, 246, 0.15);
+ border-color: var(--primary);
+ color: var(--primary);
+ }
+
+ .audio-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin-bottom: 0.75rem;
+ }
+
+ .audio-toggle input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+ accent-color: var(--primary);
+ }
+
+ .audio-toggle label {
+ font-size: 0.75rem;
+ color: #999;
+ cursor: pointer;
+ }
+
+ .microphone-select {
+ width: 100%;
+ padding: 0.5rem 0.75rem;
+ background: var(--input);
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ color: var(--foreground);
+ font-size: 0.75rem;
+ outline: none;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ }
+
+ .microphone-select:focus {
+ border-color: var(--primary);
+ }
+
+ .microphone-select:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+
+ .audio-level-container {
+ margin-top: 0.75rem;
+ padding: 0.5rem;
+ background: rgba(255, 255, 255, 0.02);
+ border-radius: var(--radius-sm);
+ }
+
+ .audio-level-label {
+ font-size: 0.65rem;
+ color: #666;
+ margin-bottom: 0.25rem;
+ }
+
+ .audio-level-bar {
+ height: 8px;
+ background: var(--input);
+ border-radius: 4px;
+ overflow: hidden;
+ }
+
+ .audio-level-fill {
+ height: 100%;
+ background: linear-gradient(90deg, #22c55e, #84cc16, #eab308);
+ border-radius: 4px;
+ transition: width 0.1s ease;
+ }
+
+ .start-recording-btn {
+ width: 100%;
+ padding: 0.75rem;
+ background: #dc2626;
+ border: none;
+ border-radius: var(--radius-sm);
+ color: white;
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ margin-top: 1rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.5rem;
+ }
+
+ .start-recording-btn:hover {
+ background: #b91c1c;
+ }
+
+ .start-recording-btn .rec-dot {
+ width: 10px;
+ height: 10px;
+ background: white;
+ border-radius: 50%;
+ }
+
+ /* Preview Modal */
+ .preview-modal-overlay {
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background: rgba(0, 0, 0, 0.8);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ backdrop-filter: blur(4px);
+ }
+
+ .preview-modal {
+ width: 90%;
+ max-width: 800px;
+ background: #0c0c0c;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: var(--radius-lg);
+ overflow: hidden;
+ box-shadow: 0 25px 50px rgba(0, 0, 0, 0.5);
+ }
+
+ .preview-modal-header {
+ padding: 1rem 1.25rem;
+ background: rgba(255, 255, 255, 0.02);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .preview-modal-title {
+ font-size: 0.9rem;
+ font-weight: 500;
+ color: #ccc;
+ }
+
+ .preview-modal-close {
+ width: 28px;
+ height: 28px;
+ background: transparent;
+ border: none;
+ color: #666;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: var(--radius-sm);
+ font-size: 1.2rem;
+ transition: all 0.15s ease;
+ }
+
+ .preview-modal-close:hover {
+ background: rgba(255, 255, 255, 0.05);
+ color: #999;
+ }
+
+ .preview-modal-content {
+ padding: 1.25rem;
+ }
+
+ .preview-video-container {
+ background: #000;
+ border-radius: var(--radius-sm);
+ overflow: hidden;
+ aspect-ratio: 16 / 9;
+ }
+
+ .preview-video {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+ }
+
+ .preview-modal-actions {
+ padding: 1rem 1.25rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.75rem;
+ }
+
+ .preview-btn {
+ padding: 0.6rem 1.25rem;
+ border-radius: var(--radius-sm);
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ }
+
+ .preview-btn.secondary {
+ background: transparent;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ color: #999;
+ }
+
+ .preview-btn.secondary:hover {
+ border-color: rgba(255, 255, 255, 0.2);
+ color: #ccc;
+ }
+
+ .preview-btn.primary {
+ background: var(--primary);
+ border: none;
+ color: white;
+ }
+
+ .preview-btn.primary:hover {
+ background: #2563eb;
+ }
${this.editingProperties.length > 0 ? html`
@@ -654,9 +1045,120 @@ export class WccProperties extends DeesElement {
${this.isFullscreen ? 'fullscreen_exit' : 'fullscreen'}
+
+
${this.warning ? html`${this.warning}
` : null}
+
+
+ ${this.recordingState === 'options' ? html`
+
+
+
+
+
Record Area
+
+
+
+
+
+
+
+
Audio
+
+ this.handleAudioToggle((e.target as HTMLInputElement).checked)}
+ />
+
+
+
+ ${this.audioEnabled ? html`
+
+
+ ${this.selectedMicrophoneId ? html`
+
+ ` : null}
+ ` : null}
+
+
+
+
+
+ ` : null}
+
+
+ ${this.recordingState === 'preview' && this.previewVideoUrl ? html`
+ {
+ if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) {
+ this.discardRecording();
+ }
+ }}>
+
+
+
+
+
+
+
+
+
+ ` : null}
`;
}
@@ -994,4 +1496,273 @@ export class WccProperties extends DeesElement {
})
);
}
+
+ // ==================== Recording Methods ====================
+
+ private formatDuration(seconds: number): string {
+ const mins = Math.floor(seconds / 60);
+ const secs = seconds % 60;
+ return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
+ }
+
+ private handleRecordingButtonClick() {
+ if (this.recordingState === 'recording') {
+ this.stopRecording();
+ } else if (this.recordingState === 'idle') {
+ this.recordingState = 'options';
+ // Don't request permissions here - just show the options panel
+ // Permissions will be requested when user enables audio or starts recording
+ } else if (this.recordingState === 'options') {
+ this.recordingState = 'idle';
+ this.stopAudioMonitoring();
+ }
+ }
+
+ private async loadMicrophones(requestPermission: boolean = false) {
+ try {
+ // Only request permission if explicitly asked (when user enables audio toggle)
+ if (requestPermission) {
+ await navigator.mediaDevices.getUserMedia({ audio: true }).then(stream => {
+ stream.getTracks().forEach(track => track.stop());
+ });
+ }
+
+ const devices = await navigator.mediaDevices.enumerateDevices();
+ this.availableMicrophones = devices.filter(d => d.kind === 'audioinput');
+
+ // Auto-select the first microphone if available and we have permission
+ if (requestPermission && this.availableMicrophones.length > 0 && !this.selectedMicrophoneId) {
+ this.selectedMicrophoneId = this.availableMicrophones[0].deviceId;
+ // Start monitoring after auto-selecting
+ await this.startAudioMonitoring();
+ }
+ } catch (error) {
+ console.error('Error loading microphones:', error);
+ this.availableMicrophones = [];
+ }
+ }
+
+ private async handleAudioToggle(enabled: boolean) {
+ this.audioEnabled = enabled;
+ if (enabled) {
+ // Request permission and load microphones when user explicitly enables audio
+ await this.loadMicrophones(true);
+ } else {
+ this.stopAudioMonitoring();
+ this.selectedMicrophoneId = '';
+ this.audioLevel = 0;
+ }
+ }
+
+ private async handleMicrophoneChange(deviceId: string) {
+ this.selectedMicrophoneId = deviceId;
+ if (deviceId) {
+ await this.startAudioMonitoring();
+ } else {
+ this.stopAudioMonitoring();
+ this.audioLevel = 0;
+ }
+ }
+
+ private async startAudioMonitoring() {
+ this.stopAudioMonitoring();
+
+ if (!this.selectedMicrophoneId) return;
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ audio: { deviceId: { exact: this.selectedMicrophoneId } }
+ });
+
+ this.audioContext = new AudioContext();
+ const source = this.audioContext.createMediaStreamSource(stream);
+ this.audioAnalyser = this.audioContext.createAnalyser();
+ this.audioAnalyser.fftSize = 256;
+ source.connect(this.audioAnalyser);
+
+ const dataArray = new Uint8Array(this.audioAnalyser.frequencyBinCount);
+
+ this.audioMonitoringInterval = window.setInterval(() => {
+ if (this.audioAnalyser) {
+ this.audioAnalyser.getByteFrequencyData(dataArray);
+ const average = dataArray.reduce((a, b) => a + b) / dataArray.length;
+ this.audioLevel = Math.min(100, (average / 128) * 100);
+ }
+ }, 50);
+
+ // Store stream for cleanup
+ this.currentStream = stream;
+ } catch (error) {
+ console.error('Error starting audio monitoring:', error);
+ this.audioLevel = 0;
+ }
+ }
+
+ private stopAudioMonitoring() {
+ if (this.audioMonitoringInterval) {
+ clearInterval(this.audioMonitoringInterval);
+ this.audioMonitoringInterval = null;
+ }
+ if (this.audioContext) {
+ this.audioContext.close();
+ this.audioContext = null;
+ }
+ if (this.currentStream) {
+ this.currentStream.getTracks().forEach(track => track.stop());
+ this.currentStream = null;
+ }
+ this.audioAnalyser = null;
+ }
+
+ private async startRecording() {
+ try {
+ // Stop audio monitoring before recording
+ this.stopAudioMonitoring();
+
+ // Get video stream based on mode
+ const displayMediaOptions: DisplayMediaStreamOptions = {
+ video: {
+ displaySurface: this.recordingMode === 'viewport' ? 'browser' : 'monitor'
+ } as MediaTrackConstraints,
+ audio: false
+ };
+
+ // Add preferCurrentTab hint for viewport mode
+ if (this.recordingMode === 'viewport') {
+ (displayMediaOptions as any).preferCurrentTab = true;
+ }
+
+ const videoStream = await navigator.mediaDevices.getDisplayMedia(displayMediaOptions);
+
+ // If viewport mode, try to crop to viewport element using Element Capture API
+ if (this.recordingMode === 'viewport') {
+ try {
+ const wccFrame = await this.dashboardRef.wccFrame;
+ const viewport = await wccFrame.getViewportElement();
+
+ // Check if Element Capture API is available (Chrome 104+)
+ if ('CropTarget' in window) {
+ const cropTarget = await (window as any).CropTarget.fromElement(viewport);
+ const [videoTrack] = videoStream.getVideoTracks();
+ await (videoTrack as any).cropTo(cropTarget);
+ }
+ } catch (e) {
+ console.warn('Element Capture not supported, recording full tab:', e);
+ }
+ }
+
+ // Combine video with audio if enabled
+ let combinedStream = videoStream;
+ if (this.audioEnabled && this.selectedMicrophoneId) {
+ try {
+ const audioStream = await navigator.mediaDevices.getUserMedia({
+ audio: { deviceId: { exact: this.selectedMicrophoneId } }
+ });
+ combinedStream = new MediaStream([
+ ...videoStream.getVideoTracks(),
+ ...audioStream.getAudioTracks()
+ ]);
+ } catch (audioError) {
+ console.warn('Could not add audio:', audioError);
+ }
+ }
+
+ // Store stream for cleanup
+ this.currentStream = combinedStream;
+
+ // Create MediaRecorder
+ const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9')
+ ? 'video/webm;codecs=vp9'
+ : 'video/webm';
+
+ this.mediaRecorder = new MediaRecorder(combinedStream, { mimeType });
+ this.recordedChunks = [];
+
+ this.mediaRecorder.ondataavailable = (e) => {
+ if (e.data.size > 0) {
+ this.recordedChunks.push(e.data);
+ }
+ };
+
+ this.mediaRecorder.onstop = () => this.handleRecordingComplete();
+
+ // Handle stream ending (user clicks "Stop sharing")
+ videoStream.getVideoTracks()[0].onended = () => {
+ if (this.recordingState === 'recording') {
+ this.stopRecording();
+ }
+ };
+
+ this.mediaRecorder.start(1000); // Capture in 1-second chunks
+
+ // Start duration timer
+ this.recordingDuration = 0;
+ this.durationInterval = window.setInterval(() => {
+ this.recordingDuration++;
+ }, 1000);
+
+ this.recordingState = 'recording';
+ } catch (error) {
+ console.error('Error starting recording:', error);
+ this.recordingState = 'idle';
+ }
+ }
+
+ private stopRecording() {
+ if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') {
+ this.mediaRecorder.stop();
+ }
+
+ if (this.durationInterval) {
+ clearInterval(this.durationInterval);
+ this.durationInterval = null;
+ }
+ }
+
+ private handleRecordingComplete() {
+ // Create blob from recorded chunks
+ this.recordedBlob = new Blob(this.recordedChunks, { type: 'video/webm' });
+
+ // Create preview URL
+ if (this.previewVideoUrl) {
+ URL.revokeObjectURL(this.previewVideoUrl);
+ }
+ this.previewVideoUrl = URL.createObjectURL(this.recordedBlob);
+
+ // Stop all tracks
+ if (this.currentStream) {
+ this.currentStream.getTracks().forEach(track => track.stop());
+ this.currentStream = null;
+ }
+
+ this.recordingState = 'preview';
+ }
+
+ private discardRecording() {
+ if (this.previewVideoUrl) {
+ URL.revokeObjectURL(this.previewVideoUrl);
+ this.previewVideoUrl = '';
+ }
+ this.recordedBlob = null;
+ this.recordedChunks = [];
+ this.recordingDuration = 0;
+ this.recordingState = 'idle';
+ }
+
+ private downloadRecording() {
+ if (!this.recordedBlob) return;
+
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const filename = `wcctools-recording-${timestamp}.webm`;
+
+ const a = document.createElement('a');
+ a.href = this.previewVideoUrl;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ // Clean up after download
+ this.discardRecording();
+ }
}