428 lines
11 KiB
TypeScript
428 lines
11 KiB
TypeScript
|
|
import {
|
||
|
|
customElement,
|
||
|
|
DeesElement,
|
||
|
|
type TemplateResult,
|
||
|
|
html,
|
||
|
|
property,
|
||
|
|
css,
|
||
|
|
cssManager,
|
||
|
|
} from '@design.estate/dees-element';
|
||
|
|
import { DeesIcon } from '@design.estate/dees-catalog';
|
||
|
|
import { demo } from './eco-applauncher-soundmenu.demo.js';
|
||
|
|
|
||
|
|
// Ensure dees-icon is registered
|
||
|
|
DeesIcon;
|
||
|
|
|
||
|
|
declare global {
|
||
|
|
interface HTMLElementTagNameMap {
|
||
|
|
'eco-applauncher-soundmenu': EcoApplauncherSoundmenu;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface IAudioDevice {
|
||
|
|
id: string;
|
||
|
|
name: string;
|
||
|
|
type: 'speaker' | 'headphones' | 'bluetooth' | 'hdmi';
|
||
|
|
active?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
@customElement('eco-applauncher-soundmenu')
|
||
|
|
export class EcoApplauncherSoundmenu extends DeesElement {
|
||
|
|
public static demo = demo;
|
||
|
|
public static demoGroup = 'App Launcher';
|
||
|
|
|
||
|
|
public static styles = [
|
||
|
|
cssManager.defaultStyles,
|
||
|
|
css`
|
||
|
|
:host {
|
||
|
|
display: block;
|
||
|
|
position: relative;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-container {
|
||
|
|
background: ${cssManager.bdTheme('#ffffff', 'hsl(240 6% 10%)')};
|
||
|
|
border: 1px solid ${cssManager.bdTheme('hsl(0 0% 90%)', 'hsl(240 5% 20%)')};
|
||
|
|
border-radius: 12px;
|
||
|
|
box-shadow: ${cssManager.bdTheme(
|
||
|
|
'0 8px 32px rgba(0, 0, 0, 0.15)',
|
||
|
|
'0 8px 32px rgba(0, 0, 0, 0.4)'
|
||
|
|
)};
|
||
|
|
min-width: 280px;
|
||
|
|
overflow: hidden;
|
||
|
|
opacity: 0;
|
||
|
|
transform: scale(0.95) translateY(-8px);
|
||
|
|
transition: all 0.2s ease-out;
|
||
|
|
pointer-events: none;
|
||
|
|
}
|
||
|
|
|
||
|
|
:host([open]) .menu-container {
|
||
|
|
opacity: 1;
|
||
|
|
transform: scale(1) translateY(0);
|
||
|
|
pointer-events: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-header {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
justify-content: space-between;
|
||
|
|
padding: 16px;
|
||
|
|
border-bottom: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-title {
|
||
|
|
font-size: 15px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 10%)', 'hsl(0 0% 98%)')};
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 10px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-section {
|
||
|
|
padding: 20px 16px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-slider-container {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 12px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-icon {
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 40%)', 'hsl(0 0% 70%)')};
|
||
|
|
cursor: pointer;
|
||
|
|
transition: color 0.15s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-icon:hover {
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-icon.muted {
|
||
|
|
color: hsl(0 72% 51%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-slider {
|
||
|
|
flex: 1;
|
||
|
|
height: 6px;
|
||
|
|
background: ${cssManager.bdTheme('hsl(0 0% 88%)', 'hsl(240 5% 20%)')};
|
||
|
|
border-radius: 3px;
|
||
|
|
position: relative;
|
||
|
|
cursor: pointer;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-fill {
|
||
|
|
height: 100%;
|
||
|
|
background: hsl(217 91% 60%);
|
||
|
|
border-radius: 3px;
|
||
|
|
transition: width 0.1s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-fill.muted {
|
||
|
|
background: ${cssManager.bdTheme('hsl(0 0% 70%)', 'hsl(0 0% 40%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-thumb {
|
||
|
|
position: absolute;
|
||
|
|
top: 50%;
|
||
|
|
transform: translate(-50%, -50%);
|
||
|
|
width: 16px;
|
||
|
|
height: 16px;
|
||
|
|
background: white;
|
||
|
|
border-radius: 50%;
|
||
|
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
|
||
|
|
cursor: grab;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-thumb:active {
|
||
|
|
cursor: grabbing;
|
||
|
|
}
|
||
|
|
|
||
|
|
.volume-percentage {
|
||
|
|
min-width: 36px;
|
||
|
|
text-align: right;
|
||
|
|
font-size: 14px;
|
||
|
|
font-weight: 500;
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 30%)', 'hsl(0 0% 80%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-divider {
|
||
|
|
height: 1px;
|
||
|
|
background: ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.section-title {
|
||
|
|
padding: 12px 16px 8px;
|
||
|
|
font-size: 12px;
|
||
|
|
font-weight: 600;
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 50%)')};
|
||
|
|
text-transform: uppercase;
|
||
|
|
letter-spacing: 0.5px;
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-list {
|
||
|
|
max-height: 160px;
|
||
|
|
overflow-y: auto;
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-item {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 12px;
|
||
|
|
padding: 10px 16px;
|
||
|
|
cursor: pointer;
|
||
|
|
transition: background 0.15s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-item:hover {
|
||
|
|
background: ${cssManager.bdTheme('hsl(0 0% 96%)', 'hsl(240 5% 15%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-item.active {
|
||
|
|
background: ${cssManager.bdTheme('hsl(217 91% 95%)', 'hsl(217 91% 60% / 0.15)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-item.active:hover {
|
||
|
|
background: ${cssManager.bdTheme('hsl(217 91% 92%)', 'hsl(217 91% 60% / 0.25)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-icon {
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 50%)', 'hsl(0 0% 60%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-item.active .device-icon {
|
||
|
|
color: hsl(217 91% 60%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-name {
|
||
|
|
flex: 1;
|
||
|
|
font-size: 14px;
|
||
|
|
color: ${cssManager.bdTheme('hsl(0 0% 20%)', 'hsl(0 0% 90%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.device-check {
|
||
|
|
color: hsl(217 91% 60%);
|
||
|
|
}
|
||
|
|
|
||
|
|
.menu-footer {
|
||
|
|
padding: 12px 16px;
|
||
|
|
border-top: 1px solid ${cssManager.bdTheme('hsl(0 0% 92%)', 'hsl(240 5% 15%)')};
|
||
|
|
}
|
||
|
|
|
||
|
|
.settings-link {
|
||
|
|
display: flex;
|
||
|
|
align-items: center;
|
||
|
|
gap: 8px;
|
||
|
|
font-size: 14px;
|
||
|
|
color: hsl(217 91% 60%);
|
||
|
|
cursor: pointer;
|
||
|
|
transition: color 0.15s ease;
|
||
|
|
}
|
||
|
|
|
||
|
|
.settings-link:hover {
|
||
|
|
color: hsl(217 91% 50%);
|
||
|
|
}
|
||
|
|
`,
|
||
|
|
];
|
||
|
|
|
||
|
|
@property({ type: Boolean, reflect: true })
|
||
|
|
accessor open = false;
|
||
|
|
|
||
|
|
@property({ type: Number })
|
||
|
|
accessor volume = 50;
|
||
|
|
|
||
|
|
@property({ type: Boolean })
|
||
|
|
accessor muted = false;
|
||
|
|
|
||
|
|
@property({ type: Array })
|
||
|
|
accessor outputDevices: IAudioDevice[] = [];
|
||
|
|
|
||
|
|
@property({ type: String })
|
||
|
|
accessor activeDeviceId: string | null = null;
|
||
|
|
|
||
|
|
private boundHandleClickOutside = this.handleClickOutside.bind(this);
|
||
|
|
private isDragging = false;
|
||
|
|
|
||
|
|
public render(): TemplateResult {
|
||
|
|
const volumeIcon = this.getVolumeIcon();
|
||
|
|
|
||
|
|
return html`
|
||
|
|
<div class="menu-container">
|
||
|
|
<div class="menu-header">
|
||
|
|
<span class="menu-title">
|
||
|
|
<dees-icon .icon=${volumeIcon} .iconSize=${18}></dees-icon>
|
||
|
|
Sound
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="volume-section">
|
||
|
|
<div class="volume-slider-container">
|
||
|
|
<dees-icon
|
||
|
|
class="volume-icon ${this.muted ? 'muted' : ''}"
|
||
|
|
.icon=${this.muted ? 'lucide:volumeX' : 'lucide:volume2'}
|
||
|
|
.iconSize=${20}
|
||
|
|
@click=${this.handleMuteToggle}
|
||
|
|
></dees-icon>
|
||
|
|
<div
|
||
|
|
class="volume-slider"
|
||
|
|
@click=${this.handleSliderClick}
|
||
|
|
@mousedown=${this.handleSliderMouseDown}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
class="volume-fill ${this.muted ? 'muted' : ''}"
|
||
|
|
style="width: ${this.muted ? 0 : this.volume}%"
|
||
|
|
></div>
|
||
|
|
<div
|
||
|
|
class="volume-thumb"
|
||
|
|
style="left: ${this.muted ? 0 : this.volume}%"
|
||
|
|
></div>
|
||
|
|
</div>
|
||
|
|
<span class="volume-percentage">${this.muted ? 0 : this.volume}%</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
${this.outputDevices.length > 0 ? html`
|
||
|
|
<div class="menu-divider"></div>
|
||
|
|
<div class="section-title">Output</div>
|
||
|
|
<div class="device-list">
|
||
|
|
${this.outputDevices.map((device) => this.renderDeviceItem(device))}
|
||
|
|
</div>
|
||
|
|
` : ''}
|
||
|
|
|
||
|
|
<div class="menu-footer">
|
||
|
|
<div class="settings-link" @click=${this.handleSettingsClick}>
|
||
|
|
<dees-icon .icon=${'lucide:settings'} .iconSize=${14}></dees-icon>
|
||
|
|
Sound Settings...
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private renderDeviceItem(device: IAudioDevice): TemplateResult {
|
||
|
|
const isActive = device.id === this.activeDeviceId;
|
||
|
|
const icon = this.getDeviceIcon(device.type);
|
||
|
|
|
||
|
|
return html`
|
||
|
|
<div
|
||
|
|
class="device-item ${isActive ? 'active' : ''}"
|
||
|
|
@click=${() => this.handleDeviceSelect(device)}
|
||
|
|
>
|
||
|
|
<dees-icon class="device-icon" .icon=${icon} .iconSize=${18}></dees-icon>
|
||
|
|
<span class="device-name">${device.name}</span>
|
||
|
|
${isActive ? html`
|
||
|
|
<dees-icon class="device-check" .icon=${'lucide:check'} .iconSize=${16}></dees-icon>
|
||
|
|
` : ''}
|
||
|
|
</div>
|
||
|
|
`;
|
||
|
|
}
|
||
|
|
|
||
|
|
private getVolumeIcon(): string {
|
||
|
|
if (this.muted || this.volume === 0) return 'lucide:volumeX';
|
||
|
|
if (this.volume < 33) return 'lucide:volume';
|
||
|
|
if (this.volume < 66) return 'lucide:volume1';
|
||
|
|
return 'lucide:volume2';
|
||
|
|
}
|
||
|
|
|
||
|
|
private getDeviceIcon(type: IAudioDevice['type']): string {
|
||
|
|
switch (type) {
|
||
|
|
case 'headphones':
|
||
|
|
return 'lucide:headphones';
|
||
|
|
case 'bluetooth':
|
||
|
|
return 'lucide:bluetooth';
|
||
|
|
case 'hdmi':
|
||
|
|
return 'lucide:monitor';
|
||
|
|
case 'speaker':
|
||
|
|
default:
|
||
|
|
return 'lucide:speaker';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleMuteToggle(): void {
|
||
|
|
this.muted = !this.muted;
|
||
|
|
this.dispatchEvent(new CustomEvent('mute-toggle', {
|
||
|
|
detail: { muted: this.muted },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleSliderClick(e: MouseEvent): void {
|
||
|
|
const slider = e.currentTarget as HTMLElement;
|
||
|
|
const rect = slider.getBoundingClientRect();
|
||
|
|
const percentage = Math.round(((e.clientX - rect.left) / rect.width) * 100);
|
||
|
|
this.setVolume(Math.max(0, Math.min(100, percentage)));
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleSliderMouseDown(e: MouseEvent): void {
|
||
|
|
this.isDragging = true;
|
||
|
|
const slider = e.currentTarget as HTMLElement;
|
||
|
|
|
||
|
|
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||
|
|
if (!this.isDragging) return;
|
||
|
|
const rect = slider.getBoundingClientRect();
|
||
|
|
const percentage = Math.round(((moveEvent.clientX - rect.left) / rect.width) * 100);
|
||
|
|
this.setVolume(Math.max(0, Math.min(100, percentage)));
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleMouseUp = () => {
|
||
|
|
this.isDragging = false;
|
||
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
||
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
||
|
|
};
|
||
|
|
|
||
|
|
document.addEventListener('mousemove', handleMouseMove);
|
||
|
|
document.addEventListener('mouseup', handleMouseUp);
|
||
|
|
}
|
||
|
|
|
||
|
|
private setVolume(value: number): void {
|
||
|
|
this.volume = value;
|
||
|
|
if (this.muted && value > 0) {
|
||
|
|
this.muted = false;
|
||
|
|
}
|
||
|
|
this.dispatchEvent(new CustomEvent('volume-change', {
|
||
|
|
detail: { volume: this.volume },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleDeviceSelect(device: IAudioDevice): void {
|
||
|
|
this.activeDeviceId = device.id;
|
||
|
|
this.dispatchEvent(new CustomEvent('device-select', {
|
||
|
|
detail: { device },
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleSettingsClick(): void {
|
||
|
|
this.dispatchEvent(new CustomEvent('settings-click', {
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
private handleClickOutside(e: MouseEvent): void {
|
||
|
|
if (this.open && !this.contains(e.target as Node)) {
|
||
|
|
this.open = false;
|
||
|
|
this.dispatchEvent(new CustomEvent('menu-close', {
|
||
|
|
bubbles: true,
|
||
|
|
composed: true,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async connectedCallback(): Promise<void> {
|
||
|
|
await super.connectedCallback();
|
||
|
|
setTimeout(() => {
|
||
|
|
document.addEventListener('click', this.boundHandleClickOutside);
|
||
|
|
}, 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
async disconnectedCallback(): Promise<void> {
|
||
|
|
await super.disconnectedCallback();
|
||
|
|
document.removeEventListener('click', this.boundHandleClickOutside);
|
||
|
|
}
|
||
|
|
}
|