331 lines
8.6 KiB
TypeScript
331 lines
8.6 KiB
TypeScript
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;
|
|
}
|
|
|
|
|
|
.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<typeof setTimeout> | null = null;
|
|
private hasLoadedWaveform: boolean = false;
|
|
|
|
protected renderTileContent(): TemplateResult {
|
|
return html`
|
|
<div class="audio-content">
|
|
<dees-icon class="music-icon" icon="lucide:Music"></dees-icon>
|
|
|
|
${this.title ? html`<div class="audio-title">${this.title}</div>` : ''}
|
|
${this.artist ? html`<div class="audio-artist">${this.artist}</div>` : ''}
|
|
|
|
${this.waveformReady ? html`
|
|
<div class="waveform-container">
|
|
<canvas></canvas>
|
|
</div>
|
|
` : ''}
|
|
</div>
|
|
|
|
${this.duration > 0 ? html`
|
|
<div class="tile-badge-corner">${this.formatTime(this.duration)}</div>
|
|
` : ''}
|
|
|
|
<div class="play-overlay">
|
|
<div class="play-circle">
|
|
<dees-icon icon="lucide:Play"></dees-icon>
|
|
</div>
|
|
</div>
|
|
|
|
${this.clickable ? html`
|
|
<div class="tile-overlay">
|
|
<dees-icon icon="lucide:Headphones"></dees-icon>
|
|
<span>Play Audio</span>
|
|
</div>
|
|
` : ''}
|
|
`;
|
|
}
|
|
|
|
protected getTileClickDetail(): Record<string, unknown> {
|
|
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<void> {
|
|
this.loading = true;
|
|
|
|
try {
|
|
// Load duration via Audio element
|
|
const audio = new Audio();
|
|
audio.crossOrigin = 'anonymous';
|
|
audio.preload = 'metadata';
|
|
|
|
await new Promise<void>((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<void> {
|
|
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<PropertyKey, unknown>): Promise<void> {
|
|
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<void> {
|
|
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')}`;
|
|
}
|
|
}
|