Files
dees-wcctools/ts_web/elements/wcc-recording-panel.ts
2025-12-11 16:58:04 +00:00

1149 lines
33 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { DeesElement, customElement, html, css, property, state, type TemplateResult } from '@design.estate/dees-element';
import { RecorderService } from '../services/recorder.service.js';
import { getFFmpegService, type IConversionProgress } from '../services/ffmpeg.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;
@state()
accessor outputFormat: 'mp4' | 'webm' = 'mp4';
@state()
accessor conversionProgress: IConversionProgress | null = null;
@state()
accessor isConverting: 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); }
}
/* Format Selection Styles */
.format-section {
margin-top: 1.25rem;
padding-top: 1.25rem;
border-top: 1px solid rgba(255, 255, 255, 0.05);
}
.format-section-header {
margin-bottom: 0.75rem;
}
.format-section-title {
font-size: 0.75rem;
font-weight: 500;
color: #888;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.format-buttons {
display: flex;
gap: 0.5rem;
}
.format-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;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.format-btn:hover {
border-color: var(--primary);
color: #ccc;
}
.format-btn.selected {
background: rgba(59, 130, 246, 0.15);
border-color: var(--primary);
color: var(--primary);
}
.format-icon {
font-size: 1rem;
}
.format-note {
margin-top: 0.5rem;
padding: 0.5rem;
background: rgba(59, 130, 246, 0.1);
border-radius: var(--radius-sm);
font-size: 0.7rem;
color: #888;
display: flex;
align-items: center;
gap: 0.5rem;
}
.note-icon {
font-size: 0.9rem;
}
/* Conversion Progress Styles */
.conversion-progress {
display: flex;
align-items: center;
gap: 0.5rem;
}
.progress-text {
font-size: 0.75rem;
}
.progress-percent {
font-family: 'Consolas', 'Monaco', monospace;
font-size: 0.7rem;
color: rgba(255, 255, 255, 0.7);
}
`
];
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`
<div class="recording-options-panel">
<div class="recording-options-header">
<span class="recording-options-title">Recording Settings</span>
<button class="recording-options-close" @click=${() => this.close()}>✕</button>
</div>
<div class="recording-options-content">
<div class="recording-option-group">
<div class="recording-option-label">Record Area</div>
<div class="recording-mode-buttons">
<button
class="recording-mode-btn ${this.recordingMode === 'viewport' ? 'selected' : ''}"
@click=${() => this.recordingMode = 'viewport'}
>
Viewport Only
</button>
<button
class="recording-mode-btn ${this.recordingMode === 'screen' ? 'selected' : ''}"
@click=${() => this.recordingMode = 'screen'}
>
Entire Screen
</button>
</div>
</div>
<div class="recording-option-group">
<div class="recording-option-label">Audio</div>
<div class="audio-toggle">
<input
type="checkbox"
id="audioToggle"
?checked=${this.audioEnabled}
@change=${(e: Event) => this.handleAudioToggle((e.target as HTMLInputElement).checked)}
/>
<label for="audioToggle">Enable Microphone</label>
</div>
${this.audioEnabled ? html`
<select
class="microphone-select"
.value=${this.selectedMicrophoneId}
@change=${(e: Event) => this.handleMicrophoneChange((e.target as HTMLSelectElement).value)}
>
<option value="">Select Microphone...</option>
${this.availableMicrophones.map(mic => html`
<option value=${mic.deviceId}>${mic.label || `Microphone ${mic.deviceId.slice(0, 8)}`}</option>
`)}
</select>
${this.selectedMicrophoneId ? html`
<div class="audio-level-container">
<div class="audio-level-label">Input Level</div>
<div class="audio-level-bar">
<div class="audio-level-fill" style="width: ${this.audioLevel}%"></div>
</div>
</div>
` : null}
` : null}
</div>
<button class="start-recording-btn" @click=${() => this.startRecording()}>
<div class="rec-dot"></div>
Start Recording
</button>
</div>
</div>
`;
}
private renderPreviewModal(): TemplateResult {
return html`
<div class="preview-modal-overlay" @click=${(e: Event) => {
if ((e.target as HTMLElement).classList.contains('preview-modal-overlay')) {
this.discardRecording();
}
}}>
<div class="preview-modal">
<div class="preview-modal-header">
<span class="preview-modal-title">Recording Preview</span>
<button class="preview-modal-close" @click=${() => this.discardRecording()}>✕</button>
</div>
<div class="preview-modal-content">
<div class="preview-video-container">
<video
class="preview-video"
src=${this.previewVideoUrl}
controls
@loadedmetadata=${(e: Event) => this.handleVideoLoaded(e.target as HTMLVideoElement)}
></video>
</div>
<!-- Trim Section -->
<div class="trim-section">
<div class="trim-section-header">
<span class="trim-section-title">Trim Video</span>
<span class="trim-duration-info">
${this.formatDuration(Math.floor(this.trimEnd - this.trimStart))}
${this.trimStart > 0 || this.trimEnd < this.videoDuration
? `(trimmed from ${this.formatDuration(Math.floor(this.videoDuration))})`
: ''}
</span>
</div>
<div
class="trim-timeline"
@mousedown=${(e: MouseEvent) => this.handleTimelineClick(e)}
@mousemove=${(e: MouseEvent) => this.handleTimelineDrag(e)}
@mouseup=${() => this.handleTimelineDragEnd()}
@mouseleave=${() => this.handleTimelineDragEnd()}
>
<div class="trim-track"></div>
<div
class="trim-selected"
style="left: ${this.getHandlePositionStyle(this.trimStart)}; right: ${this.getHandlePositionFromEndStyle(this.trimEnd)};"
></div>
<div
class="trim-handle start-handle"
style="left: ${this.getHandlePositionStyle(this.trimStart)};"
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'start'; }}
></div>
<div
class="trim-handle end-handle"
style="left: ${this.getHandlePositionStyle(this.trimEnd)};"
@mousedown=${(e: MouseEvent) => { e.stopPropagation(); this.isDraggingTrim = 'end'; }}
></div>
</div>
<div class="trim-time-labels">
<span>${this.formatDuration(Math.floor(this.trimStart))}</span>
<span>${this.formatDuration(Math.floor(this.trimEnd))}</span>
</div>
<div class="trim-actions">
<button class="trim-action-btn" @click=${() => this.resetTrim()}>
Reset Trim
</button>
<button class="trim-action-btn" @click=${() => this.previewTrimmedSection()}>
Preview Selection
</button>
</div>
</div>
<!-- Format Selection Section -->
<div class="format-section">
<div class="format-section-header">
<span class="format-section-title">Output Format</span>
</div>
<div class="format-buttons">
<button
class="format-btn ${this.outputFormat === 'mp4' ? 'selected' : ''}"
@click=${() => this.outputFormat = 'mp4'}
>
<span class="format-icon">📱</span>
MP4 (Universal)
</button>
<button
class="format-btn ${this.outputFormat === 'webm' ? 'selected' : ''}"
@click=${() => this.outputFormat = 'webm'}
>
<span class="format-icon">🌐</span>
WebM (Web Only)
</button>
</div>
${this.outputFormat === 'mp4' ? html`
<div class="format-note">
<span class="note-icon"></span>
MP4 requires conversion (~31MB download on first use)
</div>
` : null}
</div>
</div>
<div class="preview-modal-actions">
<button class="preview-btn secondary" @click=${() => this.discardRecording()}>Discard</button>
<button
class="preview-btn primary"
?disabled=${this.isExporting || this.isConverting}
@click=${() => this.downloadRecording()}
>
${this.isConverting ? html`
<div class="conversion-progress">
<span class="export-spinner"></span>
<span class="progress-text">${this.conversionProgress?.message || 'Converting...'}</span>
${this.conversionProgress?.progress !== undefined ? html`
<span class="progress-percent">${this.conversionProgress.progress}%</span>
` : null}
</div>
` : this.isExporting ? html`<span class="export-spinner"></span>Exporting...` : `Download ${this.outputFormat.toUpperCase()}`}
</button>
</div>
</div>
</div>
`;
}
// ==================== Audio Methods ====================
private async handleAudioToggle(enabled: boolean): Promise<void> {
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<void> {
this.selectedMicrophoneId = deviceId;
if (deviceId) {
await this.recorderService.startAudioMonitoring(deviceId);
} else {
this.recorderService.stopAudioMonitoring();
this.audioLevel = 0;
}
}
// ==================== Recording Methods ====================
private async startRecording(): Promise<void> {
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<void> {
const recordedBlob = this.recorderService.recordedBlob;
if (!recordedBlob) return;
this.isExporting = true;
try {
let blobToDownload: Blob;
// Step 1: Handle trimming if needed
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;
}
// Step 2: Convert to MP4 if selected
if (this.outputFormat === 'mp4') {
this.isConverting = true;
this.isExporting = false; // Switch to conversion state
try {
const ffmpegService = getFFmpegService();
blobToDownload = await ffmpegService.convertToMp4({
inputBlob: blobToDownload,
outputFormat: 'mp4',
onProgress: (progress) => {
this.conversionProgress = progress;
}
});
} catch (conversionError) {
console.error('MP4 conversion failed:', conversionError);
// Offer WebM fallback
const useFallback = confirm(
'MP4 conversion failed. Would you like to download as WebM instead?'
);
if (!useFallback) {
this.isConverting = false;
this.conversionProgress = null;
return;
}
// Continue with original WebM blob (already set)
this.outputFormat = 'webm';
}
this.isConverting = false;
this.conversionProgress = null;
}
// Step 3: Trigger download
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const extension = this.outputFormat;
const filename = `wcctools-recording-${timestamp}.${extension}`;
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;
this.isConverting = false;
this.conversionProgress = null;
}
}
// ==================== Trim Methods ====================
private handleVideoLoaded(video: HTMLVideoElement): void {
// WebM files from MediaRecorder may have Infinity/NaN duration
// Fall back to the tracked recording duration
const duration = Number.isFinite(video.duration) ? video.duration : this.recordingDuration;
this.videoDuration = duration;
this.trimStart = 0;
this.trimEnd = 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 getHandlePositionStyle(time: number): string {
if (this.videoDuration === 0) return '12px';
const percentage = time / this.videoDuration;
// Formula: 12px padding + percentage of remaining width (total - 24px padding)
// At 0%: 12px (left edge of track)
// At 100%: calc(100% - 12px) (right edge of track)
return `calc(12px + ${(percentage * 100).toFixed(2)}% - ${(percentage * 24).toFixed(2)}px)`;
}
private getHandlePositionFromEndStyle(time: number): string {
if (this.videoDuration === 0) return '12px';
const percentage = time / this.videoDuration;
const remainingPercentage = 1 - percentage;
// For CSS 'right' property: distance from right edge
// At trimEnd = 100%: right = 12px (at right edge of track)
// At trimEnd = 0%: right = calc(100% - 12px) (at left edge of track)
return `calc(12px + ${(remainingPercentage * 100).toFixed(2)}% - ${(remainingPercentage * 24).toFixed(2)}px)`;
}
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<void> {
await super.disconnectedCallback();
this.recorderService.dispose();
if (this.previewVideoUrl) {
URL.revokeObjectURL(this.previewVideoUrl);
}
}
}