612 lines
16 KiB
TypeScript
612 lines
16 KiB
TypeScript
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`
|
|
<style>
|
|
:host {
|
|
display: block;
|
|
position: relative;
|
|
width: 100%;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
}
|
|
|
|
.viewer-container {
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
background: ${cssManager.bdTheme('hsl(0 0% 97%)', 'hsl(215 20% 10%)')};
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 16px;
|
|
padding: 0 16px;
|
|
height: 48px;
|
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(215 20% 15%)')};
|
|
border-bottom: 1px solid ${cssManager.bdTheme('#e5e7eb', 'hsl(217 25% 22%)')};
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.toolbar-group--end {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
margin-left: auto;
|
|
}
|
|
|
|
.toolbar-button {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
width: 32px;
|
|
height: 32px;
|
|
border: none;
|
|
background: transparent;
|
|
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.toolbar-button dees-icon {
|
|
font-size: 16px;
|
|
}
|
|
|
|
.toolbar-button:hover {
|
|
background: ${cssManager.bdTheme('hsl(214 31% 92%)', 'hsl(217 25% 22%)')};
|
|
color: ${cssManager.bdTheme('#09090b', '#fafafa')};
|
|
}
|
|
|
|
.toolbar-button:active {
|
|
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
|
}
|
|
|
|
.toolbar-button.active {
|
|
color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
|
}
|
|
|
|
.toolbar-title {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
min-width: 0;
|
|
}
|
|
|
|
.time-display {
|
|
font-size: 13px;
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
color: ${cssManager.bdTheme('hsl(215 16% 45%)', 'hsl(215 16% 75%)')};
|
|
min-width: 90px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.volume-group {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.volume-slider {
|
|
width: 70px;
|
|
height: 4px;
|
|
-webkit-appearance: none;
|
|
appearance: none;
|
|
background: ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
|
|
border-radius: 2px;
|
|
outline: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.volume-slider::-webkit-slider-thumb {
|
|
-webkit-appearance: none;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
|
cursor: pointer;
|
|
}
|
|
|
|
.volume-slider::-moz-range-thumb {
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
|
border: none;
|
|
cursor: pointer;
|
|
}
|
|
|
|
.content-area {
|
|
flex: 1;
|
|
position: relative;
|
|
overflow: hidden;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.waveform-container {
|
|
position: absolute;
|
|
inset: 0;
|
|
cursor: pointer;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.waveform-container canvas {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: block;
|
|
}
|
|
|
|
.seekbar-container {
|
|
width: 80%;
|
|
max-width: 600px;
|
|
height: 6px;
|
|
cursor: pointer;
|
|
border-radius: 3px;
|
|
background: ${cssManager.bdTheme('hsl(214 31% 91%)', 'hsl(217 25% 22%)')};
|
|
overflow: hidden;
|
|
}
|
|
|
|
.seekbar-fill {
|
|
height: 100%;
|
|
background: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
|
border-radius: 3px;
|
|
transition: width 0.1s linear;
|
|
}
|
|
|
|
.error-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
color: ${cssManager.bdTheme('#71717a', '#a1a1aa')};
|
|
}
|
|
|
|
.error-overlay .error-icon {
|
|
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
|
|
font-size: 32px;
|
|
}
|
|
|
|
.error-text {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.loading-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 3px solid ${cssManager.bdTheme('hsl(214 31% 86%)', 'hsl(217 25% 28%)')};
|
|
border-top-color: ${cssManager.bdTheme('hsl(217 91% 60%)', 'hsl(213 93% 68%)')};
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
</style>
|
|
|
|
<div class="viewer-container">
|
|
<div class="toolbar">
|
|
<div class="toolbar-group">
|
|
<button class="toolbar-button" @click=${this.togglePlay}>
|
|
<dees-icon icon="lucide:${this.isPlaying ? 'Pause' : 'Play'}"></dees-icon>
|
|
</button>
|
|
<span class="time-display">
|
|
${this.formatTime(this.currentTime)} / ${this.formatTime(this.duration)}
|
|
</span>
|
|
</div>
|
|
|
|
${titleText ? html`
|
|
<span class="toolbar-title">${titleText}</span>
|
|
` : ''}
|
|
|
|
<div class="toolbar-group--end">
|
|
<button
|
|
class="toolbar-button ${this.loop ? 'active' : ''}"
|
|
@click=${this.toggleLoop}
|
|
title="Loop"
|
|
>
|
|
<dees-icon icon="lucide:Repeat"></dees-icon>
|
|
</button>
|
|
|
|
<div class="volume-group">
|
|
<button class="toolbar-button" @click=${this.toggleMute} title="${this.isMuted ? 'Unmute' : 'Mute'}">
|
|
<dees-icon icon="lucide:${this.isMuted || this.volume === 0 ? 'VolumeX' : this.volume < 0.5 ? 'Volume1' : 'Volume2'}"></dees-icon>
|
|
</button>
|
|
<input
|
|
class="volume-slider"
|
|
type="range"
|
|
min="0"
|
|
max="1"
|
|
step="0.01"
|
|
.value=${String(this.isMuted ? 0 : this.volume)}
|
|
@input=${this.handleVolumeChange}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="content-area">
|
|
${this.error ? html`
|
|
<div class="error-overlay">
|
|
<dees-icon class="error-icon" icon="lucide:MusicOff"></dees-icon>
|
|
<span class="error-text">${this.error}</span>
|
|
</div>
|
|
` : this.loading ? html`
|
|
<div class="loading-overlay">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
` : this.showWaveform ? html`
|
|
<div class="waveform-container" @click=${this.handleWaveformClick}>
|
|
<canvas></canvas>
|
|
</div>
|
|
` : html`
|
|
<div class="seekbar-container" @click=${this.handleSeekbarClick}>
|
|
<div class="seekbar-fill" style="width: ${this.duration ? (this.currentTime / this.duration) * 100 : 0}%"></div>
|
|
</div>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
public async connectedCallback(): Promise<void> {
|
|
await super.connectedCallback();
|
|
if (this.src) {
|
|
this.initAudio();
|
|
}
|
|
}
|
|
|
|
public async disconnectedCallback(): Promise<void> {
|
|
await super.disconnectedCallback();
|
|
this.cleanup();
|
|
}
|
|
|
|
public async updated(changedProperties: Map<PropertyKey, unknown>): Promise<void> {
|
|
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<void> {
|
|
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;
|
|
}
|
|
}
|