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:
1 parent
df8bdb7ac0
commit
91fa7c7f5f
2 files changed
+550
No files matched your search
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user