diff --git a/package.json b/package.json index b5308e3..a031f76 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "@git.zone/tsbundle": "^2.6.3", "@git.zone/tsrun": "^2.0.0", "@git.zone/tstest": "^3.1.3", - "@git.zone/tswatch": "^2.3.9", + "@git.zone/tswatch": "^2.3.10", "@push.rocks/projectinfo": "^5.0.2", "@types/node": "^25.0.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 523c6ee..2f64b74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -37,8 +37,8 @@ importers: specifier: ^3.1.3 version: 3.1.3(@push.rocks/smartserve@1.4.0)(socks@2.8.7)(typescript@5.9.3) '@git.zone/tswatch': - specifier: ^2.3.9 - version: 2.3.9(@tiptap/pm@2.27.1) + specifier: ^2.3.10 + version: 2.3.10(@tiptap/pm@2.27.1) '@push.rocks/projectinfo': specifier: ^5.0.2 version: 5.0.2 @@ -489,8 +489,8 @@ packages: resolution: {integrity: sha512-t+/cKV21JHK8X7NGAmihs5M/eMm+V+jn4R5rzfwGG97WJFAcP5qE1Os9VYtyZw3tx/NZXA2yA4abo/ELluTuRA==} hasBin: true - '@git.zone/tswatch@2.3.9': - resolution: {integrity: sha512-lm3rwkeLXrT8arsQYTTnLSobyXYio+Q70vciBTflpf2Sf4I9fd4QH/89EmKSLysJso2Gnrz63brLzTYCtbdlQQ==} + '@git.zone/tswatch@2.3.10': + resolution: {integrity: sha512-88bdzD15mYoG0T0AUTg8ATNkV/dN5ecqfiYcQRX1gJHmLrE2yqymFGkb0W0/xWgpcRakc08V+wRbSI7pqg+EOQ==} hasBin: true '@happy-dom/global-registrator@15.11.7': @@ -912,6 +912,10 @@ packages: resolution: {integrity: sha512-M7rMLdcO423JIF7PbMnqy730h4seAx8lXkP3d7yGhIXep2jizPP+KlkdbdkBdaVp7YupcFZiTnu2HY66SKVtpQ==} engines: {node: '>=20.0.0'} + '@push.rocks/smartwatch@6.2.4': + resolution: {integrity: sha512-cxGx/RJXSU45cfyJn0DNgXA1jPwmzraJhy+8J8hL2Bjn0K+DxatQRyeIvRVCSLLgBhVTN6yYaUjUtjs19gJLkA==} + engines: {node: '>=20.0.0'} + '@push.rocks/smartxml@2.0.0': resolution: {integrity: sha512-1d06zYJX4Zt8s5w5qFOUg2LAEz9ykrh9d6CQPK4WAgOBIefb1xzVEWHc7yoxicc2OkzNgC3IBCEg3s6BncZKWw==} @@ -4803,7 +4807,7 @@ snapshots: - utf-8-validate - vue - '@git.zone/tswatch@2.3.9(@tiptap/pm@2.27.1)': + '@git.zone/tswatch@2.3.10(@tiptap/pm@2.27.1)': dependencies: '@api.global/typedserver': 7.11.1(@tiptap/pm@2.27.1) '@git.zone/tsbundle': 2.6.3 @@ -4816,7 +4820,7 @@ snapshots: '@push.rocks/smartlog': 3.1.10 '@push.rocks/smartlog-destination-local': 9.0.2 '@push.rocks/smartshell': 3.3.0 - '@push.rocks/smartwatch': 6.2.3 + '@push.rocks/smartwatch': 6.2.4 '@push.rocks/taskbuffer': 3.5.0 transitivePeerDependencies: - '@nuxt/kit' @@ -5783,6 +5787,14 @@ snapshots: '@push.rocks/smartrx': 3.0.10 picomatch: 4.0.3 + '@push.rocks/smartwatch@6.2.4': + dependencies: + '@push.rocks/lik': 6.2.2 + '@push.rocks/smartenv': 6.0.0 + '@push.rocks/smartpromise': 4.2.3 + '@push.rocks/smartrx': 3.0.10 + picomatch: 4.0.3 + '@push.rocks/smartxml@2.0.0': dependencies: fast-xml-parser: 5.3.2 diff --git a/ts_web/elements/wcc-properties.ts b/ts_web/elements/wcc-properties.ts index aa116f8..9a84bdc 100644 --- a/ts_web/elements/wcc-properties.ts +++ b/ts_web/elements/wcc-properties.ts @@ -1,6 +1,8 @@ import { DeesElement, property, html, customElement, type TemplateResult, state } from '@design.estate/dees-element'; import { WccDashboard } from './wcc-dashboard.js'; import type { TTemplateFactory } from './wcctools.helpers.js'; +import './wcc-record-button.js'; +import './wcc-recording-panel.js'; export type TPropertyType = 'String' | 'Number' | 'Boolean' | 'Object' | 'Enum' | 'Array'; @@ -48,43 +50,16 @@ export class WccProperties extends DeesElement { editorError: string; }> = []; - // Recording state properties + // Recording coordination state @state() - accessor recordingState: 'idle' | 'options' | 'recording' | 'preview' = 'idle'; + accessor showRecordingPanel: boolean = false; @state() - accessor recordingMode: 'viewport' | 'screen' = 'screen'; - - @state() - accessor audioEnabled: boolean = false; - - @state() - accessor selectedMicrophoneId: string = ''; - - @state() - accessor availableMicrophones: MediaDeviceInfo[] = []; - - @state() - accessor audioLevel: number = 0; + accessor isRecording: boolean = false; @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 { @@ -550,360 +525,6 @@ 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`
@@ -1046,118 +667,24 @@ export class WccProperties extends DeesElement {
-
this.handleRecordingButtonClick()} - > - ${this.recordingState === 'recording' ? html` -
- ${this.formatDuration(this.recordingDuration)} - ` : html` -
- `} -
+ this.handleRecordButtonClick()} + > ${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 - -
-
-
- -
-
-
- - -
-
-
+ + ${this.showRecordingPanel ? html` + { this.isRecording = true; }} + @recording-stop=${() => { this.isRecording = false; }} + @duration-update=${(e: CustomEvent) => { this.recordingDuration = e.detail.duration; }} + @close=${() => { this.showRecordingPanel = false; this.isRecording = false; this.recordingDuration = 0; }} + > ` : null} `; } @@ -1499,270 +1026,16 @@ 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()); - }); + private handleRecordButtonClick() { + if (this.isRecording) { + // Stop recording by calling the panel's stopRecording method + const panel = this.shadowRoot?.querySelector('wcc-recording-panel') as any; + if (panel && panel.stopRecording) { + panel.stopRecording(); } - - 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; + // Toggle the recording panel + this.showRecordingPanel = !this.showRecordingPanel; } } - - 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(); - } } diff --git a/ts_web/elements/wcc-record-button.ts b/ts_web/elements/wcc-record-button.ts new file mode 100644 index 0000000..3865eac --- /dev/null +++ b/ts_web/elements/wcc-record-button.ts @@ -0,0 +1,108 @@ +import { DeesElement, customElement, html, css, property, type TemplateResult } from '@design.estate/dees-element'; + +@customElement('wcc-record-button') +export class WccRecordButton extends DeesElement { + @property({ type: String }) + accessor state: 'idle' | 'recording' = 'idle'; + + @property({ type: Number }) + accessor duration: number = 0; + + public static styles = [ + css` + :host { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + cursor: pointer; + transition: all 0.15s ease; + color: #666; + user-select: none; + } + + :host(:hover) { + background: rgba(239, 68, 68, 0.05); + color: #f87171; + } + + :host(.recording) { + background: rgba(239, 68, 68, 0.15); + color: #f87171; + } + + .content { + display: flex; + align-items: center; + justify-content: center; + gap: 0.25rem; + } + + .rec-icon { + width: 12px; + height: 12px; + border-radius: 50%; + background: currentColor; + } + + :host(.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; + } + ` + ]; + + 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')}`; + } + + public render(): TemplateResult { + return html` +
+
+ ${this.state === 'recording' ? html` + ${this.formatDuration(this.duration)} + ` : null} +
+ `; + } + + async connectedCallback(): Promise { + await super.connectedCallback(); + this.addEventListener('click', this.handleClick); + } + + async disconnectedCallback(): Promise { + await super.disconnectedCallback(); + this.removeEventListener('click', this.handleClick); + } + + private handleClick = (): void => { + this.dispatchEvent(new CustomEvent('record-click', { + bubbles: true, + composed: true + })); + }; + + updated(changedProperties: Map): void { + super.updated(changedProperties); + if (changedProperties.has('state')) { + if (this.state === 'recording') { + this.classList.add('recording'); + } else { + this.classList.remove('recording'); + } + } + } +} diff --git a/ts_web/elements/wcc-recording-panel.ts b/ts_web/elements/wcc-recording-panel.ts new file mode 100644 index 0000000..572fbef --- /dev/null +++ b/ts_web/elements/wcc-recording-panel.ts @@ -0,0 +1,966 @@ +import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element'; +import { RecorderService } from '../services/recorder.service.js'; +import type { WccDashboard } from './wcc-dashboard.js'; + +@customElement('wcc-recording-panel') +export class WccRecordingPanel extends DeesElement { + // External configuration + @property({ attribute: false }) + accessor dashboardRef: WccDashboard; + + // Panel state + @state() + accessor panelState: 'options' | 'recording' | 'preview' = 'options'; + + // Recording options + @state() + accessor recordingMode: 'viewport' | 'screen' = 'viewport'; + + @state() + accessor audioEnabled: boolean = false; + + @state() + accessor selectedMicrophoneId: string = ''; + + @state() + accessor availableMicrophones: MediaDeviceInfo[] = []; + + @state() + accessor audioLevel: number = 0; + + // Recording state + @state() + accessor recordingDuration: number = 0; + + // Preview/trim state + @state() + accessor previewVideoUrl: string = ''; + + @state() + accessor trimStart: number = 0; + + @state() + accessor trimEnd: number = 0; + + @state() + accessor videoDuration: number = 0; + + @state() + accessor isDraggingTrim: 'start' | 'end' | null = null; + + @state() + accessor isExporting: boolean = false; + + // Service instance + private recorderService: RecorderService; + + constructor() { + super(); + this.recorderService = new RecorderService({ + onDurationUpdate: (duration) => { + this.recordingDuration = duration; + this.dispatchEvent(new CustomEvent('duration-update', { + detail: { duration }, + bubbles: true, + composed: true + })); + }, + onRecordingComplete: (blob) => { + this.handleRecordingComplete(blob); + }, + onAudioLevelUpdate: (level) => { + this.audioLevel = level; + }, + onStreamEnded: () => { + this.stopRecording(); + } + }); + } + + public static styles = [ + css` + :host { + /* CSS Variables */ + --background: #0a0a0a; + --foreground: #e5e5e5; + --input: #141414; + --primary: #3b82f6; + --border: rgba(255, 255, 255, 0.06); + --radius-sm: 2px; + --radius-md: 4px; + --radius-lg: 6px; + } + + /* 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; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + } + + .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); + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif; + } + + .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; + } + + .preview-btn.primary:disabled { + background: #1e3a5f; + cursor: not-allowed; + opacity: 0.7; + } + + /* Trim Timeline Styles */ + .trim-section { + margin-top: 1.25rem; + padding-top: 1.25rem; + border-top: 1px solid rgba(255, 255, 255, 0.05); + } + + .trim-section-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; + } + + .trim-section-title { + font-size: 0.75rem; + font-weight: 500; + color: #888; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + .trim-duration-info { + font-size: 0.7rem; + color: #666; + font-family: 'Consolas', 'Monaco', monospace; + } + + .trim-timeline { + position: relative; + height: 48px; + background: var(--input); + border-radius: var(--radius-sm); + margin-bottom: 0.75rem; + user-select: none; + } + + .trim-track { + position: absolute; + top: 50%; + left: 12px; + right: 12px; + height: 6px; + background: #333; + transform: translateY(-50%); + border-radius: 3px; + } + + .trim-selected { + position: absolute; + top: 50%; + height: 6px; + background: var(--primary); + transform: translateY(-50%); + border-radius: 3px; + pointer-events: none; + } + + .trim-handle { + position: absolute; + top: 50%; + width: 16px; + height: 36px; + background: white; + border: 2px solid var(--primary); + border-radius: 4px; + transform: translate(-50%, -50%); + cursor: ew-resize; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.15s ease, transform 0.1s ease; + } + + .trim-handle:hover { + background: #e0e0e0; + } + + .trim-handle:active { + background: var(--primary); + transform: translate(-50%, -50%) scale(1.05); + } + + .trim-handle::before { + content: ''; + width: 2px; + height: 16px; + background: #666; + border-radius: 1px; + } + + .trim-handle:active::before { + background: white; + } + + .trim-time-labels { + display: flex; + justify-content: space-between; + font-size: 0.65rem; + color: #666; + font-family: 'Consolas', 'Monaco', monospace; + padding: 0 12px; + } + + .trim-actions { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + } + + .trim-action-btn { + flex: 1; + padding: 0.5rem 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; + } + + .trim-action-btn:hover { + border-color: var(--primary); + color: #ccc; + } + + .export-spinner { + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top-color: white; + animation: spin 0.8s linear infinite; + margin-right: 0.5rem; + } + + @keyframes spin { + to { transform: rotate(360deg); } + } + ` + ]; + + public render(): TemplateResult { + if (this.panelState === 'options') { + return this.renderOptionsPanel(); + } else if (this.panelState === 'preview') { + return this.renderPreviewModal(); + } + return html``; + } + + private renderOptionsPanel(): TemplateResult { + return html` +
+
+ Recording Settings + +
+
+
+
Record Area
+
+ + +
+
+ +
+
Audio
+
+ this.handleAudioToggle((e.target as HTMLInputElement).checked)} + /> + +
+ + ${this.audioEnabled ? html` + + + ${this.selectedMicrophoneId ? html` +
+
Input Level
+
+
+
+
+ ` : null} + ` : null} +
+ + +
+
+ `; + } + + private renderPreviewModal(): TemplateResult { + return html` +
{ + if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) { + this.discardRecording(); + } + }}> +
+
+ Recording Preview + +
+
+
+ +
+ + +
+
+ Trim Video + + ${this.formatDuration(Math.floor(this.trimEnd - this.trimStart))} + ${this.trimStart > 0 || this.trimEnd < this.videoDuration + ? `(trimmed from ${this.formatDuration(Math.floor(this.videoDuration))})` + : ''} + +
+ +
this.handleTimelineClick(e)} + @mousemove=${(e: MouseEvent) => this.handleTimelineDrag(e)} + @mouseup=${() => this.handleTimelineDragEnd()} + @mouseleave=${() => this.handleTimelineDragEnd()} + > +
+
+
{ e.stopPropagation(); this.isDraggingTrim = 'start'; }} + >
+
{ e.stopPropagation(); this.isDraggingTrim = 'end'; }} + >
+
+ +
+ ${this.formatDuration(Math.floor(this.trimStart))} + ${this.formatDuration(Math.floor(this.trimEnd))} +
+ +
+ + +
+
+
+
+ + +
+
+
+ `; + } + + // ==================== Audio Methods ==================== + + private async handleAudioToggle(enabled: boolean): Promise { + this.audioEnabled = enabled; + if (enabled) { + this.availableMicrophones = await this.recorderService.loadMicrophones(true); + if (this.availableMicrophones.length > 0 && !this.selectedMicrophoneId) { + this.selectedMicrophoneId = this.availableMicrophones[0].deviceId; + await this.recorderService.startAudioMonitoring(this.selectedMicrophoneId); + } + } else { + this.recorderService.stopAudioMonitoring(); + this.selectedMicrophoneId = ''; + this.audioLevel = 0; + } + } + + private async handleMicrophoneChange(deviceId: string): Promise { + this.selectedMicrophoneId = deviceId; + if (deviceId) { + await this.recorderService.startAudioMonitoring(deviceId); + } else { + this.recorderService.stopAudioMonitoring(); + this.audioLevel = 0; + } + } + + // ==================== Recording Methods ==================== + + private async startRecording(): Promise { + try { + let viewportElement: HTMLElement | undefined; + if (this.recordingMode === 'viewport' && this.dashboardRef) { + const wccFrame = await this.dashboardRef.wccFrame; + viewportElement = await wccFrame.getViewportElement(); + } + + await this.recorderService.startRecording({ + mode: this.recordingMode, + audioDeviceId: this.audioEnabled ? this.selectedMicrophoneId : undefined, + viewportElement + }); + + this.panelState = 'recording'; + this.dispatchEvent(new CustomEvent('recording-start', { + bubbles: true, + composed: true + })); + } catch (error) { + console.error('Failed to start recording:', error); + this.panelState = 'options'; + } + } + + public stopRecording(): void { + this.recorderService.stopRecording(); + } + + private handleRecordingComplete(blob: Blob): void { + if (this.previewVideoUrl) { + URL.revokeObjectURL(this.previewVideoUrl); + } + this.previewVideoUrl = URL.createObjectURL(blob); + this.panelState = 'preview'; + this.dispatchEvent(new CustomEvent('recording-stop', { + bubbles: true, + composed: true + })); + } + + private discardRecording(): void { + if (this.previewVideoUrl) { + URL.revokeObjectURL(this.previewVideoUrl); + this.previewVideoUrl = ''; + } + this.recorderService.reset(); + this.trimStart = 0; + this.trimEnd = 0; + this.videoDuration = 0; + this.isExporting = false; + this.recordingDuration = 0; + this.close(); + } + + private async downloadRecording(): Promise { + const recordedBlob = this.recorderService.recordedBlob; + if (!recordedBlob) return; + + this.isExporting = true; + + try { + let blobToDownload: Blob; + + const needsTrim = this.trimStart > 0.1 || this.trimEnd < this.videoDuration - 0.1; + + if (needsTrim) { + const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement; + if (video) { + blobToDownload = await this.recorderService.exportTrimmedVideo(video, this.trimStart, this.trimEnd); + } else { + blobToDownload = recordedBlob; + } + } else { + blobToDownload = recordedBlob; + } + + const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + const filename = `wcctools-recording-${timestamp}.webm`; + + const url = URL.createObjectURL(blobToDownload); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + + this.discardRecording(); + } catch (error) { + console.error('Error exporting video:', error); + this.isExporting = false; + } + } + + // ==================== Trim Methods ==================== + + private handleVideoLoaded(video: HTMLVideoElement): void { + this.videoDuration = video.duration; + this.trimStart = 0; + this.trimEnd = video.duration; + } + + 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 getHandlePosition(time: number): number { + if (this.videoDuration === 0) return 12; + const percentage = time / this.videoDuration; + const trackWidth = 336; + return 12 + (percentage * trackWidth); + } + + private getHandlePositionFromEnd(time: number): number { + if (this.videoDuration === 0) return 12; + const percentage = (this.videoDuration - time) / this.videoDuration; + const trackWidth = 336; + return 12 + (percentage * trackWidth); + } + + private handleTimelineClick(e: MouseEvent): void { + if (this.isDraggingTrim) return; + + const timeline = e.currentTarget as HTMLElement; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24))); + const time = percentage * this.videoDuration; + + const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement; + if (video) { + video.currentTime = time; + } + } + + private handleTimelineDrag(e: MouseEvent): void { + if (!this.isDraggingTrim) return; + + const timeline = e.currentTarget as HTMLElement; + const rect = timeline.getBoundingClientRect(); + const x = e.clientX - rect.left; + const percentage = Math.max(0, Math.min(1, (x - 12) / (rect.width - 24))); + const time = percentage * this.videoDuration; + + const minDuration = 1; + + if (this.isDraggingTrim === 'start') { + this.trimStart = Math.min(time, this.trimEnd - minDuration); + this.trimStart = Math.max(0, this.trimStart); + } else if (this.isDraggingTrim === 'end') { + this.trimEnd = Math.max(time, this.trimStart + minDuration); + this.trimEnd = Math.min(this.videoDuration, this.trimEnd); + } + + const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement; + if (video) { + video.currentTime = this.isDraggingTrim === 'start' ? this.trimStart : this.trimEnd; + } + } + + private handleTimelineDragEnd(): void { + this.isDraggingTrim = null; + } + + private resetTrim(): void { + this.trimStart = 0; + this.trimEnd = this.videoDuration; + + const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement; + if (video) { + video.currentTime = 0; + } + } + + private previewTrimmedSection(): void { + const video = this.shadowRoot?.querySelector('.preview-video') as HTMLVideoElement; + if (!video) return; + + video.currentTime = this.trimStart; + video.play(); + + const checkTime = () => { + if (video.currentTime >= this.trimEnd) { + video.pause(); + video.removeEventListener('timeupdate', checkTime); + } + }; + + video.addEventListener('timeupdate', checkTime); + } + + // ==================== Lifecycle ==================== + + private close(): void { + this.recorderService.stopAudioMonitoring(); + this.dispatchEvent(new CustomEvent('close', { + bubbles: true, + composed: true + })); + } + + async disconnectedCallback(): Promise { + await super.disconnectedCallback(); + this.recorderService.dispose(); + if (this.previewVideoUrl) { + URL.revokeObjectURL(this.previewVideoUrl); + } + } +} diff --git a/ts_web/index.ts b/ts_web/index.ts index 7ff717b..384379d 100644 --- a/ts_web/index.ts +++ b/ts_web/index.ts @@ -2,6 +2,11 @@ import { WccDashboard } from './elements/wcc-dashboard.js'; import { LitElement } from 'lit'; import type { TTemplateFactory } from './elements/wcctools.helpers.js'; +// Export recording components and service +export { RecorderService, type IRecorderEvents, type IRecordingOptions } from './services/recorder.service.js'; +export { WccRecordButton } from './elements/wcc-record-button.js'; +export { WccRecordingPanel } from './elements/wcc-recording-panel.js'; + const setupWccTools = ( elementsArg?: { [key: string]: LitElement }, pagesArg?: Record diff --git a/ts_web/services/recorder.service.ts b/ts_web/services/recorder.service.ts new file mode 100644 index 0000000..be552ab --- /dev/null +++ b/ts_web/services/recorder.service.ts @@ -0,0 +1,391 @@ +/** + * RecorderService - Handles all MediaRecorder, audio monitoring, and video export logic + */ + +export interface IRecorderEvents { + onDurationUpdate?: (duration: number) => void; + onRecordingComplete?: (blob: Blob) => void; + onAudioLevelUpdate?: (level: number) => void; + onError?: (error: Error) => void; + onStreamEnded?: () => void; +} + +export interface IRecordingOptions { + mode: 'viewport' | 'screen'; + audioDeviceId?: string; + viewportElement?: HTMLElement; +} + +export class RecorderService { + // Recording state + private mediaRecorder: MediaRecorder | null = null; + private recordedChunks: Blob[] = []; + private durationInterval: number | null = null; + private _duration: number = 0; + private _recordedBlob: Blob | null = null; + private _isRecording: boolean = false; + + // Audio monitoring state + private audioContext: AudioContext | null = null; + private audioAnalyser: AnalyserNode | null = null; + private audioMonitoringInterval: number | null = null; + private monitoringStream: MediaStream | null = null; + + // Current recording stream + private currentStream: MediaStream | null = null; + + // Event callbacks + private events: IRecorderEvents = {}; + + constructor(events?: IRecorderEvents) { + if (events) { + this.events = events; + } + } + + // Public getters + get isRecording(): boolean { + return this._isRecording; + } + + get duration(): number { + return this._duration; + } + + get recordedBlob(): Blob | null { + return this._recordedBlob; + } + + // Update event callbacks + setEvents(events: IRecorderEvents): void { + this.events = { ...this.events, ...events }; + } + + // ==================== Microphone Management ==================== + + async loadMicrophones(requestPermission: boolean = false): Promise { + try { + if (requestPermission) { + // Request permission by getting a temporary stream + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + stream.getTracks().forEach(track => track.stop()); + } + + const devices = await navigator.mediaDevices.enumerateDevices(); + return devices.filter(d => d.kind === 'audioinput'); + } catch (error) { + console.error('Error loading microphones:', error); + return []; + } + } + + async startAudioMonitoring(deviceId: string): Promise { + this.stopAudioMonitoring(); + + if (!deviceId) return; + + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: { deviceId: { exact: deviceId } } + }); + + this.monitoringStream = stream; + 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; + const level = Math.min(100, (average / 128) * 100); + this.events.onAudioLevelUpdate?.(level); + } + }, 50); + } catch (error) { + console.error('Error starting audio monitoring:', error); + this.events.onAudioLevelUpdate?.(0); + } + } + + stopAudioMonitoring(): void { + if (this.audioMonitoringInterval) { + clearInterval(this.audioMonitoringInterval); + this.audioMonitoringInterval = null; + } + if (this.audioContext) { + this.audioContext.close(); + this.audioContext = null; + } + if (this.monitoringStream) { + this.monitoringStream.getTracks().forEach(track => track.stop()); + this.monitoringStream = null; + } + this.audioAnalyser = null; + } + + // ==================== Recording Control ==================== + + async startRecording(options: IRecordingOptions): Promise { + try { + // Stop audio monitoring before recording + this.stopAudioMonitoring(); + + // Get video stream based on mode + const displayMediaOptions: DisplayMediaStreamOptions = { + video: { + displaySurface: options.mode === 'viewport' ? 'browser' : 'monitor' + } as MediaTrackConstraints, + audio: false + }; + + // Add preferCurrentTab hint for viewport mode + if (options.mode === '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 (options.mode === 'viewport' && options.viewportElement) { + try { + if ('CropTarget' in window) { + const cropTarget = await (window as any).CropTarget.fromElement(options.viewportElement); + 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 (options.audioDeviceId) { + try { + const audioStream = await navigator.mediaDevices.getUserMedia({ + audio: { deviceId: { exact: options.audioDeviceId } } + }); + 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._isRecording) { + this.stopRecording(); + this.events.onStreamEnded?.(); + } + }; + + this.mediaRecorder.start(1000); // Capture in 1-second chunks + + // Start duration timer + this._duration = 0; + this.durationInterval = window.setInterval(() => { + this._duration++; + this.events.onDurationUpdate?.(this._duration); + }, 1000); + + this._isRecording = true; + } catch (error) { + console.error('Error starting recording:', error); + this._isRecording = false; + this.events.onError?.(error as Error); + throw error; + } + } + + stopRecording(): void { + if (this.mediaRecorder && this.mediaRecorder.state !== 'inactive') { + this.mediaRecorder.stop(); + } + + if (this.durationInterval) { + clearInterval(this.durationInterval); + this.durationInterval = null; + } + } + + private handleRecordingComplete(): void { + // Create blob from recorded chunks + this._recordedBlob = new Blob(this.recordedChunks, { type: 'video/webm' }); + + // Stop all tracks + if (this.currentStream) { + this.currentStream.getTracks().forEach(track => track.stop()); + this.currentStream = null; + } + + this._isRecording = false; + this.events.onRecordingComplete?.(this._recordedBlob); + } + + // ==================== Trim & Export ==================== + + async exportTrimmedVideo( + videoElement: HTMLVideoElement, + trimStart: number, + trimEnd: number + ): Promise { + return new Promise((resolve, reject) => { + // Create a canvas for capturing frames + const canvas = document.createElement('canvas'); + canvas.width = videoElement.videoWidth || 1280; + canvas.height = videoElement.videoHeight || 720; + const ctx = canvas.getContext('2d'); + + if (!ctx) { + reject(new Error('Could not get canvas context')); + return; + } + + // Create canvas stream for video + const canvasStream = canvas.captureStream(30); + + // Try to capture audio from video element + let combinedStream: MediaStream; + + try { + // Create audio context to capture video's audio + const audioCtx = new AudioContext(); + const source = audioCtx.createMediaElementSource(videoElement); + const destination = audioCtx.createMediaStreamDestination(); + source.connect(destination); + source.connect(audioCtx.destination); // Also play through speakers + + // Combine video (from canvas) and audio (from video element) + combinedStream = new MediaStream([ + ...canvasStream.getVideoTracks(), + ...destination.stream.getAudioTracks() + ]); + + // Store audioCtx for cleanup + const cleanup = () => { + audioCtx.close(); + }; + + this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, cleanup, resolve, reject); + } catch (audioError) { + console.warn('Could not capture audio, recording video only:', audioError); + combinedStream = canvasStream; + this.recordTrimmedStream(videoElement, canvas, ctx, combinedStream, trimStart, trimEnd, () => {}, resolve, reject); + } + }); + } + + private recordTrimmedStream( + video: HTMLVideoElement, + canvas: HTMLCanvasElement, + ctx: CanvasRenderingContext2D, + stream: MediaStream, + trimStart: number, + trimEnd: number, + cleanup: () => void, + resolve: (blob: Blob) => void, + reject: (error: Error) => void + ): void { + const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') + ? 'video/webm;codecs=vp9' + : 'video/webm'; + + const recorder = new MediaRecorder(stream, { mimeType }); + const chunks: Blob[] = []; + + recorder.ondataavailable = (e) => { + if (e.data.size > 0) { + chunks.push(e.data); + } + }; + + recorder.onstop = () => { + cleanup(); + resolve(new Blob(chunks, { type: 'video/webm' })); + }; + + recorder.onerror = (e) => { + cleanup(); + reject(new Error('Recording error: ' + e)); + }; + + // Seek to trim start + video.currentTime = trimStart; + + video.onseeked = () => { + // Start recording + recorder.start(100); + + // Start playing + video.play(); + + // Draw frames to canvas + const drawFrame = () => { + if (video.currentTime >= trimEnd || video.paused || video.ended) { + video.pause(); + video.onseeked = null; + + // Give a small delay before stopping to ensure last frame is captured + setTimeout(() => { + if (recorder.state === 'recording') { + recorder.stop(); + } + }, 100); + return; + } + + ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + requestAnimationFrame(drawFrame); + }; + + drawFrame(); + }; + } + + // ==================== Cleanup ==================== + + reset(): void { + this._recordedBlob = null; + this.recordedChunks = []; + this._duration = 0; + this._isRecording = false; + } + + dispose(): void { + this.stopRecording(); + this.stopAudioMonitoring(); + this.reset(); + + if (this.currentStream) { + this.currentStream.getTracks().forEach(track => track.stop()); + this.currentStream = null; + } + } +}