import { DeesElement, html, customElement, type TemplateResult, property, state, cssManager, } from '@design.estate/dees-element'; import '../../00group-utility/dees-icon/dees-icon.js'; import { demo } from './demo.js'; declare global { interface HTMLElementTagNameMap { 'dees-audio-viewer': DeesAudioViewer; } } @customElement('dees-audio-viewer') export class DeesAudioViewer extends DeesElement { public static demo = demo; public static demoGroups = ['Media']; @property() accessor src: string = ''; @property() accessor title: string = ''; @property() accessor artist: string = ''; @property({ type: Boolean }) accessor showWaveform: boolean = true; @property({ type: Boolean }) accessor autoplay: boolean = false; @property({ type: Boolean }) accessor loop: boolean = false; @state() accessor isPlaying: boolean = false; @state() accessor currentTime: number = 0; @state() accessor duration: number = 0; @state() accessor volume: number = 1; @state() accessor isMuted: boolean = false; @state() accessor loading: boolean = false; @state() accessor error: string = ''; @state() accessor waveformData: number[] = []; @state() accessor waveformReady: boolean = false; private audioElement: HTMLAudioElement | null = null; private canvasElement: HTMLCanvasElement | null = null; private animFrameId: number = 0; private volumeBeforeMute: number = 1; public render(): TemplateResult { const titleText = this.title && this.artist ? `${this.title} — ${this.artist}` : this.title || this.artist || ''; return html`
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
${titleText ? html` ${titleText} ` : ''}
${this.error ? html`
${this.error}
` : this.loading ? html`
` : this.showWaveform ? html`
` : html`
`}
`; } public async connectedCallback(): Promise { await super.connectedCallback(); if (this.src) { this.initAudio(); } } public async disconnectedCallback(): Promise { await super.disconnectedCallback(); this.cleanup(); } public async updated(changedProperties: Map): Promise { super.updated(changedProperties); if (changedProperties.has('src') && this.src) { this.cleanup(); this.initAudio(); } if (changedProperties.has('waveformData') || changedProperties.has('currentTime')) { this.drawWaveform(); } } public play(): void { this.audioElement?.play(); } public pause(): void { this.audioElement?.pause(); } public togglePlay(): void { if (this.isPlaying) { this.pause(); } else { this.play(); } } public seek(time: number): void { if (this.audioElement) { this.audioElement.currentTime = time; } } public setVolume(v: number): void { this.volume = Math.max(0, Math.min(1, v)); if (this.audioElement) { this.audioElement.volume = this.volume; } if (this.volume > 0) { this.isMuted = false; } } public toggleMute(): void { if (this.isMuted) { this.isMuted = false; this.volume = this.volumeBeforeMute || 0.5; if (this.audioElement) { this.audioElement.volume = this.volume; } } else { this.volumeBeforeMute = this.volume; this.isMuted = true; if (this.audioElement) { this.audioElement.volume = 0; } } } private toggleLoop(): void { this.loop = !this.loop; if (this.audioElement) { this.audioElement.loop = this.loop; } } private initAudio(): void { this.audioElement = new Audio(); this.audioElement.crossOrigin = 'anonymous'; this.audioElement.src = this.src; this.audioElement.volume = this.isMuted ? 0 : this.volume; this.audioElement.loop = this.loop; this.audioElement.addEventListener('loadedmetadata', () => { this.duration = this.audioElement!.duration; this.loading = false; }); this.audioElement.addEventListener('play', () => { this.isPlaying = true; this.startTimeUpdate(); }); this.audioElement.addEventListener('pause', () => { this.isPlaying = false; this.stopTimeUpdate(); }); this.audioElement.addEventListener('ended', () => { this.isPlaying = false; this.stopTimeUpdate(); }); this.audioElement.addEventListener('error', () => { this.error = 'Failed to load audio'; this.loading = false; }); this.audioElement.addEventListener('timeupdate', () => { this.currentTime = this.audioElement!.currentTime; }); if (this.autoplay) { this.audioElement.play().catch(() => { // Autoplay blocked by browser }); } if (this.showWaveform) { this.loadWaveform(); } } private async loadWaveform(): Promise { try { this.loading = true; 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 = 200; 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); } // Normalize const max = Math.max(...waveform); this.waveformData = waveform.map((v) => (max > 0 ? v / max : 0)); this.waveformReady = true; this.loading = false; await audioContext.close(); } catch { this.waveformReady = false; this.loading = false; } } private drawWaveform(): void { if (!this.showWaveform || !this.waveformReady) return; const canvas = this.shadowRoot?.querySelector('canvas') as HTMLCanvasElement; if (!canvas) return; this.canvasElement = canvas; 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 playedRatio = this.duration > 0 ? this.currentTime / this.duration : 0; const playedBars = Math.floor(playedRatio * bars); const isDark = document.body.classList.contains('theme-dark') || window.matchMedia('(prefers-color-scheme: dark)').matches; const playedColor = isDark ? 'hsl(213 93% 68%)' : 'hsl(217 91% 60%)'; const unplayedColor = isDark ? 'hsl(217 25% 22%)' : 'hsl(214 31% 86%)'; 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.fillStyle = i < playedBars ? playedColor : unplayedColor; ctx.fillRect(x + 0.5, y, barWidth - 1, barHeight); } } private handleWaveformClick(e: MouseEvent): void { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; this.seek(ratio * this.duration); } private handleSeekbarClick(e: MouseEvent): void { const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); const ratio = (e.clientX - rect.left) / rect.width; this.seek(ratio * this.duration); } private handleVolumeChange(e: Event): void { const value = parseFloat((e.target as HTMLInputElement).value); this.setVolume(value); } private startTimeUpdate(): void { this.stopTimeUpdate(); const update = () => { if (this.audioElement && this.isPlaying) { this.currentTime = this.audioElement.currentTime; this.animFrameId = requestAnimationFrame(update); } }; this.animFrameId = requestAnimationFrame(update); } private stopTimeUpdate(): void { if (this.animFrameId) { cancelAnimationFrame(this.animFrameId); this.animFrameId = 0; } } 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')}`; } private cleanup(): void { this.stopTimeUpdate(); if (this.audioElement) { this.audioElement.pause(); this.audioElement.src = ''; this.audioElement = null; } this.isPlaying = false; this.currentTime = 0; this.duration = 0; this.waveformData = []; this.waveformReady = false; } }