feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies

This commit is contained in:
2026-01-27 10:57:42 +00:00
parent 8158b791c7
commit 162688cdb5
218 changed files with 5223 additions and 458 deletions

View File

@@ -0,0 +1,611 @@
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;
}
}