feat(components): add large set of new UI components and demos, reorganize groups, and bump a few dependencies
This commit is contained in:
611
ts_web/elements/00group-media/dees-audio-viewer/component.ts
Normal file
611
ts_web/elements/00group-media/dees-audio-viewer/component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user