import { property, state, html, customElement, css, cssManager, type TemplateResult, type CSSResult, } from '@design.estate/dees-element'; import { DeesTileBase } from '../dees-tile-shared/DeesTileBase.js'; import { tileBaseStyles } from '../dees-tile-shared/styles.js'; import { demo } from './demo.js'; declare global { interface HTMLElementTagNameMap { 'dees-tile-video': DeesTileVideo; } } @customElement('dees-tile-video') export class DeesTileVideo extends DeesTileBase { public static demo = demo; public static demoGroups = ['Media']; public static styles = [ ...tileBaseStyles, css` .video-wrapper { position: relative; width: 100%; height: 100%; overflow: hidden; background: #000; } .video-wrapper video { width: 100%; height: 100%; object-fit: cover; display: block; } .video-wrapper canvas { width: 100%; height: 100%; object-fit: cover; display: block; } .poster-image { width: 100%; height: 100%; object-fit: cover; display: block; } .duration-badge { position: absolute; bottom: 8px; right: 8px; padding: 3px 8px; background: rgba(0, 0, 0, 0.7); color: white; border-radius: 4px; font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; backdrop-filter: blur(8px); z-index: 10; } .play-overlay { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 48px; height: 48px; border-radius: 50%; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 15; pointer-events: none; transition: opacity 0.2s ease; } .play-overlay dees-icon { font-size: 20px; color: white; } .tile-container.clickable:hover .play-overlay { opacity: 0; } .video-hover-preview { position: absolute; top: 0; left: 0; width: 100%; height: 100%; object-fit: cover; z-index: 5; opacity: 0; transition: opacity 0.3s ease; } .video-hover-preview.active { opacity: 1; } `, ] as any; @property({ type: String }) accessor src: string = ''; @property({ type: String }) accessor poster: string = ''; @state() accessor duration: number = 0; @state() accessor thumbnailCaptured: boolean = false; @state() accessor isHovering: boolean = false; private thumbnailCanvas: HTMLCanvasElement | null = null; private hoverVideo: HTMLVideoElement | null = null; private hasStartedLoading: boolean = false; protected renderTileContent(): TemplateResult { return html`
${this.poster ? html` ` : this.thumbnailCaptured ? html` ` : html`
`} ${this.isHovering && this.src ? html` ` : ''}
${this.duration > 0 ? html`
${this.formatTime(this.duration)}
` : ''} ${!this.isHovering ? html`
` : ''} ${this.clickable ? html`
Play Video
` : ''} `; } protected getTileClickDetail(): Record { return { src: this.src, poster: this.poster, duration: this.duration, }; } protected onBecameVisible(): void { if (!this.hasStartedLoading && this.src) { this.hasStartedLoading = true; this.captureFirstFrame(); } } private async captureFirstFrame(): Promise { if (this.poster) { // If poster is provided, just load duration this.loadDuration(); return; } this.loading = true; try { const video = document.createElement('video'); video.crossOrigin = 'anonymous'; video.muted = true; video.preload = 'metadata'; await new Promise((resolve, reject) => { video.addEventListener('loadeddata', () => { this.duration = video.duration; // Capture the first frame video.currentTime = 0.1; // Slightly after start for better frame video.addEventListener('seeked', () => { const canvas = document.createElement('canvas'); canvas.width = video.videoWidth; canvas.height = video.videoHeight; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(video, 0, 0); this.thumbnailCanvas = canvas; this.thumbnailCaptured = true; } // Clean up video.src = ''; video.load(); resolve(); }, { once: true }); }, { once: true }); video.addEventListener('error', () => reject(new Error('Failed to load video')), { once: true }); video.src = this.src; }); this.loading = false; // Copy thumbnail to shadow DOM canvas await this.updateComplete; this.copyThumbnailToCanvas(); } catch { this.loading = false; // Don't set error for thumbnail failure this.loadDuration(); } } private loadDuration(): void { const video = document.createElement('video'); video.preload = 'metadata'; video.addEventListener('loadedmetadata', () => { this.duration = video.duration; video.src = ''; video.load(); }); video.src = this.src; } private copyThumbnailToCanvas(): void { if (!this.thumbnailCanvas) return; const canvas = this.shadowRoot?.querySelector('.video-wrapper canvas') as HTMLCanvasElement; if (!canvas) return; canvas.width = this.thumbnailCanvas.width; canvas.height = this.thumbnailCanvas.height; const ctx = canvas.getContext('2d'); if (ctx) { ctx.drawImage(this.thumbnailCanvas, 0, 0); } } protected onTileMouseEnter(): void { this.isHovering = true; } protected onTileMouseLeave(): void { this.isHovering = false; // The video element will be removed from DOM by the template this.hoverVideo = null; } private handleHoverVideoLoaded(e: Event): void { this.hoverVideo = e.target as HTMLVideoElement; this.hoverVideo.play().catch(() => { // Autoplay may be blocked }); } public async updated(changedProperties: Map): Promise { super.updated(changedProperties); if (changedProperties.has('src') && this.src && this.isVisible) { this.hasStartedLoading = true; this.thumbnailCaptured = false; this.duration = 0; this.captureFirstFrame(); } if (changedProperties.has('thumbnailCaptured') && this.thumbnailCaptured) { await this.updateComplete; this.copyThumbnailToCanvas(); } } public async disconnectedCallback(): Promise { await super.disconnectedCallback(); if (this.hoverVideo) { this.hoverVideo.pause(); this.hoverVideo.src = ''; this.hoverVideo = null; } this.thumbnailCanvas = null; } private formatTime(seconds: number): string { if (!isFinite(seconds) || seconds < 0) return '0:00'; const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; } }