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-audio': DeesTileAudio; } } @customElement('dees-tile-audio') export class DeesTileAudio extends DeesTileBase { public static demo = demo; public static demoGroups = ['Media']; public static styles = [ ...tileBaseStyles, css` .audio-content { position: relative; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; background: ${cssManager.bdTheme( 'linear-gradient(135deg, hsl(250 40% 96%), hsl(280 30% 94%))', 'linear-gradient(135deg, hsl(250 30% 16%), hsl(280 25% 14%))' )}; } .music-icon { font-size: 48px; color: ${cssManager.bdTheme('hsl(250 60% 65%)', 'hsl(250 60% 70%)')}; opacity: 0.8; } .audio-title { font-size: 12px; font-weight: 600; color: ${cssManager.bdTheme('hsl(250 20% 35%)', 'hsl(250 20% 80%)')}; text-align: center; padding: 0 16px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .audio-artist { font-size: 11px; font-weight: 400; color: ${cssManager.bdTheme('hsl(250 15% 50%)', 'hsl(250 15% 65%)')}; text-align: center; padding: 0 16px; margin-top: -12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .waveform-container { width: calc(100% - 32px); height: 40px; position: relative; overflow: hidden; } .waveform-container canvas { width: 100%; height: 100%; display: block; } .duration-badge { position: absolute; bottom: 8px; right: 8px; padding: 3px 8px; background: ${cssManager.bdTheme('hsl(0 0% 0% / 0.6)', 'hsl(0 0% 100% / 0.85)')}; color: ${cssManager.bdTheme('white', 'hsl(215 20% 12%)')}; 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: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s ease; z-index: 18; pointer-events: none; } .tile-container.clickable:hover .play-overlay { opacity: 1; } .play-circle { width: 48px; height: 48px; border-radius: 50%; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(8px); } .play-circle dees-icon { font-size: 20px; color: white; } `, ] as any; @property({ type: String }) accessor src: string = ''; @property({ type: String }) accessor title: string = ''; @property({ type: String }) accessor artist: string = ''; @state() accessor duration: number = 0; @state() accessor waveformData: number[] = []; @state() accessor waveformReady: boolean = false; @state() accessor isPreviewPlaying: boolean = false; private audioElement: HTMLAudioElement | null = null; private previewTimeout: ReturnType | null = null; private hasLoadedWaveform: boolean = false; protected renderTileContent(): TemplateResult { return html` ${this.title ? html`${this.title}` : ''} ${this.artist ? html`${this.artist}` : ''} ${this.waveformReady ? html` ` : ''} ${this.duration > 0 ? html` ${this.formatTime(this.duration)} ` : ''} ${this.clickable ? html` Play Audio ` : ''} `; } protected getTileClickDetail(): Record { return { src: this.src, title: this.title, artist: this.artist, duration: this.duration, }; } protected onBecameVisible(): void { if (!this.hasLoadedWaveform && this.src) { this.hasLoadedWaveform = true; this.loadAudioMeta(); } } private async loadAudioMeta(): Promise { this.loading = true; try { // Load duration via Audio element const audio = new Audio(); audio.crossOrigin = 'anonymous'; audio.preload = 'metadata'; await new Promise((resolve, reject) => { audio.addEventListener('loadedmetadata', () => { this.duration = audio.duration; resolve(); }, { once: true }); audio.addEventListener('error', () => reject(new Error('Failed to load audio')), { once: true }); audio.src = this.src; }); // Load waveform data await this.loadWaveform(); this.loading = false; } catch { this.loading = false; // Don't set error - audio may still be playable, just no waveform } } private async loadWaveform(): Promise { try { const response = await fetch(this.src); const arrayBuffer = await response.arrayBuffer(); const audioContext = new AudioContext(); const audioBuffer = await audioContext.decodeAudioData(arrayBuffer); const channelData = audioBuffer.getChannelData(0); const bars = 80; const blockSize = Math.floor(channelData.length / bars); const waveform: number[] = []; for (let i = 0; i < bars; i++) { let sum = 0; for (let j = 0; j < blockSize; j++) { sum += Math.abs(channelData[i * blockSize + j]); } waveform.push(sum / blockSize); } const max = Math.max(...waveform); this.waveformData = waveform.map((v) => (max > 0 ? v / max : 0)); this.waveformReady = true; await audioContext.close(); await this.updateComplete; this.drawWaveform(); } catch { this.waveformReady = false; } } private drawWaveform(): void { if (!this.waveformReady) return; const canvas = this.shadowRoot?.querySelector('.waveform-container canvas') as HTMLCanvasElement; if (!canvas) return; const container = canvas.parentElement!; const dpr = window.devicePixelRatio || 1; const width = container.clientWidth; const height = container.clientHeight; canvas.width = width * dpr; canvas.height = height * dpr; const ctx = canvas.getContext('2d'); if (!ctx) return; ctx.scale(dpr, dpr); ctx.clearRect(0, 0, width, height); const bars = this.waveformData.length; if (bars === 0) return; const barWidth = width / bars; const isDark = document.body.classList.contains('theme-dark') || window.matchMedia('(prefers-color-scheme: dark)').matches; const barColor = isDark ? 'hsl(250 50% 60%)' : 'hsl(250 50% 70%)'; ctx.fillStyle = barColor; for (let i = 0; i < bars; i++) { const amplitude = this.waveformData[i]; const barHeight = Math.max(2, amplitude * (height - 4)); const x = i * barWidth; const y = (height - barHeight) / 2; ctx.fillRect(x + 0.5, y, barWidth - 1, barHeight); } } public async updated(changedProperties: Map): Promise { super.updated(changedProperties); if (changedProperties.has('src') && this.src && this.isVisible) { this.hasLoadedWaveform = true; this.waveformReady = false; this.duration = 0; this.loadAudioMeta(); } if (changedProperties.has('waveformReady') && this.waveformReady) { await this.updateComplete; this.drawWaveform(); } } public async disconnectedCallback(): Promise { await super.disconnectedCallback(); if (this.previewTimeout) { clearTimeout(this.previewTimeout); } if (this.audioElement) { this.audioElement.pause(); this.audioElement.src = ''; this.audioElement = 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')}`; } }