From 7c8c194fd82f7b07b44ef4f699390df023f40566 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Thu, 11 Dec 2025 11:45:02 +0000 Subject: [PATCH] update --- ts_web/elements/wcc-properties.ts | 773 +++++++++++++++++++++++++++++- 1 file changed, 772 insertions(+), 1 deletion(-) 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.handleRecordingButtonClick()} + > + ${this.recordingState === 'recording' ? html` +
+ ${this.formatDuration(this.recordingDuration)} + ` : html` +
+ `} +
${this.warning ? html`
${this.warning}
` : null} + + + ${this.recordingState === 'options' ? html` +
+
+ Recording Settings + +
+
+
+
Record Area
+
+ + +
+
+ +
+
Audio
+
+ this.handleAudioToggle((e.target as HTMLInputElement).checked)} + /> + +
+ + ${this.audioEnabled ? html` + + + ${this.selectedMicrophoneId ? html` +
+
Input Level
+
+
+
+
+ ` : null} + ` : null} +
+ + +
+
+ ` : null} + + + ${this.recordingState === 'preview' && this.previewVideoUrl ? html` +
{ + if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) { + this.discardRecording(); + } + }}> +
+
+ Recording Preview + +
+
+
+ +
+
+
+ + +
+
+
+ ` : 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(); + } }