Add grouped device dropdown for ALSA Direct audio output selection

- New DeviceDropdown component with grouped options:
  - Defaults (System Default, default ALSA)
  - Bit-perfect (Auto-detected) - hw:X,Y devices with BP badge
  - Plugin Hardware - plughw:X,Y devices
  - Other Outputs - remaining devices
- Only shows for ALSA Direct backend, other backends use regular dropdown
- Pure UI change, no backend modifications
This commit is contained in:
vicrodh committed 2026-01-30 11:01:16 -06:00
1 parent df8bdb7ac0
commit 91fa7c7f5f
2 files changed
+550

No files matched your search

+508
View File
@@ -0,0 +1,508 @@
<script lang="ts">
import { ChevronDown, Search } from 'lucide-svelte';
import {
openMenu as openGlobalMenu,
closeMenu as closeGlobalMenu,
subscribe as subscribeFloatingMenu,
getActiveMenuId,
MENU_INACTIVITY_TIMEOUT
} from '$lib/stores/floatingMenuStore';
interface DeviceOption {
value: string; // Display name
id: string; // Device ID (for categorization)
isDefault?: boolean;
}
interface DeviceGroup {
key: string; // Group identifier
label: string; // Display label
devices: DeviceOption[];
}
interface Props {
value: string;
devices: DeviceOption[];
onchange: (value: string) => void;
wide?: boolean;
expandLeft?: boolean;
}
let { value, devices, onchange, wide = false, expandLeft = false }: Props = $props();
let isOpen = $state(false);
let isHovering = $state(false);
let dropdownRef = $state<HTMLDivElement | null>(null);
let menuRef = $state<HTMLDivElement | null>(null);
let searchInputRef = $state<HTMLInputElement | null>(null);
let searchQuery = $state('');
const menuId = `device-dropdown-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
// Show search when many devices
const showSearch = $derived(devices.length > 5);
// Group devices by category
const groupedDevices = $derived.by(() => {
const groups: DeviceGroup[] = [];
// Defaults group
const defaults: DeviceOption[] = [];
// Bit-perfect group (hw: devices)
const bitPerfect: DeviceOption[] = [];
// Plugin Hardware group (plughw: devices)
const pluginHw: DeviceOption[] = [];
// Other devices
const others: DeviceOption[] = [];
for (const device of devices) {
// System Default is always first
if (device.value === 'System Default') {
defaults.push(device);
}
// Default ALSA Output
else if (device.id === 'default' || device.isDefault) {
defaults.push(device);
}
// Bit-perfect direct hardware (hw:X,Y)
else if (device.id.startsWith('hw:')) {
bitPerfect.push(device);
}
// Plugin hardware (plughw:X,Y)
else if (device.id.startsWith('plughw:')) {
pluginHw.push(device);
}
// Everything else
else {
others.push(device);
}
}
if (defaults.length > 0) {
groups.push({ key: 'defaults', label: 'Defaults', devices: defaults });
}
if (bitPerfect.length > 0) {
groups.push({ key: 'bitperfect', label: 'Bit-perfect (Auto-detected)', devices: bitPerfect });
}
if (pluginHw.length > 0) {
groups.push({ key: 'pluginhw', label: 'Plugin Hardware', devices: pluginHw });
}
if (others.length > 0) {
groups.push({ key: 'others', label: 'Other Outputs', devices: others });
}
return groups;
});
// Filtered groups based on search
const filteredGroups = $derived.by(() => {
if (!searchQuery.trim()) return groupedDevices;
const query = searchQuery.toLowerCase();
return groupedDevices
.map(group => ({
...group,
devices: group.devices.filter(d =>
d.value.toLowerCase().includes(query) ||
d.id.toLowerCase().includes(query)
)
}))
.filter(group => group.devices.length > 0);
});
// Flat list of all filtered devices (for counting)
const totalFilteredDevices = $derived(
filteredGroups.reduce((sum, g) => sum + g.devices.length, 0)
);
let fixedPosition = $state<{ top: number; left: number; width: number } | null>(null);
const ITEM_HEIGHT = 36;
const GROUP_HEADER_HEIGHT = 28;
const SEARCH_HEIGHT = 48;
const MENU_PADDING = 8;
const MAX_VISIBLE_ITEMS = 6;
const MENU_GAP = 4;
const expectedMenuHeight = $derived(
showSearch
? SEARCH_HEIGHT + (MAX_VISIBLE_ITEMS * ITEM_HEIGHT) + MENU_PADDING
: Math.min(devices.length + groupedDevices.length, 10) * ITEM_HEIGHT + MENU_PADDING
);
function calculatePosition() {
if (!dropdownRef) return;
const triggerRect = dropdownRef.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const viewportWidth = window.innerWidth;
const playerHeight = 104;
const safeBottom = viewportHeight - playerHeight;
const menuHeight = menuRef?.offsetHeight || expectedMenuHeight;
const menuWidth = menuRef?.offsetWidth || (wide ? 320 : 280);
const spaceBelow = safeBottom - triggerRect.bottom - MENU_GAP;
const spaceAbove = triggerRect.top - MENU_GAP;
let top: number;
if (spaceBelow >= menuHeight) {
top = triggerRect.bottom + MENU_GAP;
} else if (spaceAbove >= menuHeight) {
top = triggerRect.top - menuHeight - MENU_GAP;
} else {
top = spaceBelow >= spaceAbove ? triggerRect.bottom + MENU_GAP : triggerRect.top - menuHeight - MENU_GAP;
}
let left: number;
if (expandLeft) {
left = triggerRect.right - menuWidth;
if (left < 8) left = 8;
} else {
left = triggerRect.left;
if (left + menuWidth > viewportWidth - 8) {
left = viewportWidth - menuWidth - 8;
}
}
fixedPosition = { top, left, width: triggerRect.width };
}
function handleClickOutside(event: MouseEvent) {
if (dropdownRef && !dropdownRef.contains(event.target as Node) &&
menuRef && !menuRef.contains(event.target as Node)) {
closeDropdown();
}
}
function openDropdown() {
calculatePosition();
openGlobalMenu(menuId);
isOpen = true;
searchQuery = '';
requestAnimationFrame(() => {
if (showSearch) {
searchInputRef?.focus();
}
calculatePosition();
});
}
function closeDropdown() {
isOpen = false;
searchQuery = '';
fixedPosition = null;
closeGlobalMenu(menuId);
}
function handleOptionClick(device: DeviceOption) {
onchange(device.value);
closeDropdown();
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
closeDropdown();
}
}
$effect(() => {
const unsubscribe = subscribeFloatingMenu(() => {
const activeId = getActiveMenuId();
if (activeId !== null && activeId !== menuId && isOpen) {
isOpen = false;
searchQuery = '';
fixedPosition = null;
}
});
return unsubscribe;
});
$effect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
const recalc = () => calculatePosition();
window.addEventListener('scroll', recalc, true);
window.addEventListener('resize', recalc);
let idleTimer: ReturnType<typeof setTimeout> | null = null;
const scheduleIdleClose = () => {
if (idleTimer) clearTimeout(idleTimer);
idleTimer = setTimeout(() => {
if (isOpen && !isHovering) closeDropdown();
}, MENU_INACTIVITY_TIMEOUT);
};
if (!isHovering) scheduleIdleClose();
const onActivity = () => {
if (!isHovering) scheduleIdleClose();
};
window.addEventListener('pointermove', onActivity, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', recalc, true);
window.removeEventListener('resize', recalc);
window.removeEventListener('pointermove', onActivity, true);
if (idleTimer) clearTimeout(idleTimer);
};
}
});
const menuMaxHeight = $derived(`${expectedMenuHeight}px`);
</script>
<div class="dropdown" class:wide bind:this={dropdownRef}>
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()}>
<span class="value-text">{value}</span>
<ChevronDown size={16} class="chevron" />
</button>
</div>
{#if isOpen && fixedPosition}
<div
class="menu"
class:searchable={showSearch}
role="listbox"
bind:this={menuRef}
onmouseenter={() => isHovering = true}
onmouseleave={() => isHovering = false}
style:top="{fixedPosition.top}px"
style:left="{fixedPosition.left}px"
style:min-width="{fixedPosition.width}px"
style:max-height={menuMaxHeight}
>
{#if showSearch}
<div class="search-container">
<Search size={14} class="search-icon" />
<input
bind:this={searchInputRef}
type="text"
class="search-input"
placeholder="Search devices..."
bind:value={searchQuery}
onkeydown={handleKeyDown}
/>
</div>
{/if}
<div
class="options-container"
class:with-search={showSearch}
style:max-height={showSearch ? `${MAX_VISIBLE_ITEMS * ITEM_HEIGHT + 100}px` : undefined}
>
{#each filteredGroups as group (group.key)}
<div class="group">
<div class="group-header">{group.label}</div>
{#each group.devices as device (device.value)}
<button
class="option"
class:selected={device.value === value}
onclick={() => handleOptionClick(device)}
title={device.id !== device.value ? `${device.value}\n${device.id}` : device.value}
>
<span class="option-text">{device.value}</span>
{#if group.key === 'bitperfect'}
<span class="badge bit-perfect">BP</span>
{/if}
</button>
{/each}
</div>
{:else}
<div class="no-results">No devices found</div>
{/each}
</div>
</div>
{/if}
<style>
.dropdown {
position: relative;
}
.dropdown.wide {
min-width: 280px;
}
.trigger {
height: 40px;
width: 280px;
padding: 0 16px;
background-color: var(--bg-tertiary);
border-radius: 8px;
border: none;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 150ms ease;
}
.dropdown.wide .trigger {
width: 280px;
}
.value-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
text-align: left;
}
.trigger:hover {
background-color: var(--bg-hover);
}
.trigger :global(.chevron) {
color: var(--text-muted);
flex-shrink: 0;
}
.menu {
position: fixed;
width: max-content;
min-width: 280px;
background-color: var(--bg-tertiary);
border-radius: 8px;
padding: 4px 0;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
z-index: 10000;
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-container {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
flex-shrink: 0;
}
.search-container :global(.search-icon) {
color: var(--text-muted);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--text-primary);
font-size: 13px;
padding: 0;
min-width: 0;
}
.search-input::placeholder {
color: var(--text-muted);
}
.options-container {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--text-muted) transparent;
flex: 1;
min-height: 0;
}
.options-container::-webkit-scrollbar {
width: 6px;
}
.options-container::-webkit-scrollbar-track {
background: transparent;
}
.options-container::-webkit-scrollbar-thumb {
background: var(--text-muted);
border-radius: 9999px;
}
.group {
margin-bottom: 4px;
}
.group:last-child {
margin-bottom: 0;
}
.group-header {
padding: 8px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
pointer-events: none;
user-select: none;
}
.option {
width: 100%;
height: 36px;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
text-align: left;
font-size: 12px;
color: var(--text-secondary);
background: none;
border: none;
cursor: pointer;
transition: background-color 150ms ease, color 150ms ease;
white-space: nowrap;
flex-shrink: 0;
}
.option-text {
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
.option:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.option.selected {
background-color: rgba(66, 133, 244, 0.15);
color: var(--text-primary);
}
.badge {
padding: 2px 5px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
flex-shrink: 0;
}
.badge.bit-perfect {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
border: 1px solid rgba(59, 130, 246, 0.3);
}
.no-results {
padding: 12px 16px;
text-align: center;
color: var(--text-muted);
font-size: 13px;
}
</style>
@@ -7,6 +7,7 @@
import { ArrowLeft, ChevronRight, ChevronDown, ChevronUp, Loader2, Sun, Moon, SunMoon } from 'lucide-svelte';
import Toggle from '../Toggle.svelte';
import Dropdown from '../Dropdown.svelte';
import DeviceDropdown from '../DeviceDropdown.svelte';
import VolumeSlider from '../VolumeSlider.svelte';
import UpdateCheckResultModal from '../updates/UpdateCheckResultModal.svelte';
import WhatsNewModal from '../updates/WhatsNewModal.svelte';
@@ -461,6 +462,39 @@
return map;
});
// Device options for grouped dropdown (ALSA Direct only)
let groupedDeviceOptions = $derived.by(() => {
// System Default is always first
const options: { value: string; id: string; isDefault?: boolean }[] = [
{ value: 'System Default', id: 'system-default', isDefault: true }
];
// Generate unique display names (same logic as deviceOptions)
const displayNames = backendDevices.map(d => {
if (d.description && selectedBackend === 'ALSA Direct') {
return d.description;
}
return needsTranslation(d.name) ? getDevicePrettyName(d.name) : d.name;
});
backendDevices.forEach((device, idx) => {
let displayName = displayNames[idx];
// If duplicate, append device ID to make unique
if (displayNames.filter(n => n === displayName).length > 1) {
displayName = `${displayName} [${device.name}]`;
}
options.push({
value: displayName,
id: device.id,
isDefault: device.is_default
});
});
return options;
});
// ALSA plugin options (derived)
let alsaPluginOptions = $derived(alsaPlugins.map(p => p.name));
@@ -1883,6 +1917,14 @@
</div>
{#if isLoadingDevices}
<span class="loading-text">Loading devices...</span>
{:else if selectedBackend === 'ALSA Direct'}
<DeviceDropdown
value={outputDevice}
devices={groupedDeviceOptions}
onchange={handleBackendDeviceChange}
wide
expandLeft
/>
{:else}
<Dropdown
value={outputDevice}