feat(ui): implement global FloatingMenu system for menu exclusivity
- Add floatingMenuStore.ts with global menu state management - Only one floating menu can be open at a time across the entire app - Standardize inactivity timeout to 2 seconds for all menus Updated components: - TrackMenu: integrate with global store, reduce timeout from 5s to 2s - AlbumMenu: integrate with global store, add inactivity timeout - Sidebar: integrate dropdown, context menu, and folder popover - PlaylistManagerView: integrate filter and sort dropdowns - Dropdown.svelte: integrate generic dropdown component The global store ensures that opening any menu (context menu, dropdown, popover) automatically closes all other open menus.
This commit is contained in:
1 parent
19756092a4
commit
f84c27b5ee
7 files changed
+520
-42
No files matched your search
@@ -12,6 +12,13 @@
|
||||
Link,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
closeMenu as closeGlobalMenu,
|
||||
subscribe as subscribeGlobal,
|
||||
getActiveMenuId,
|
||||
MENU_INACTIVITY_TIMEOUT
|
||||
} from '$lib/stores/floatingMenuStore';
|
||||
|
||||
interface Props {
|
||||
onPlayNext?: () => void;
|
||||
@@ -53,7 +60,11 @@
|
||||
let submenuStyle = $state('');
|
||||
let downloadSubmenuStyle = $state('');
|
||||
let portalTarget: HTMLElement | null = null;
|
||||
const menuId = Symbol('album-menu');
|
||||
let isHoveringMenu = $state(false);
|
||||
|
||||
// Unique menu ID for global store
|
||||
let menuIdCounter = 0;
|
||||
const menuId = `album-menu-${++menuIdCounter}-${Date.now()}`;
|
||||
|
||||
const hasQueue = $derived(!!(onPlayNext || onPlayLater));
|
||||
const hasLibrary = $derived(!!onAddToPlaylist);
|
||||
@@ -66,6 +77,16 @@
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
onOpenChange?.(false);
|
||||
closeGlobalMenu(menuId);
|
||||
}
|
||||
|
||||
function doOpenMenu() {
|
||||
openGlobalMenu(menuId);
|
||||
isOpen = true;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
onOpenChange?.(true);
|
||||
setMenuPosition();
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -79,16 +100,17 @@
|
||||
|
||||
onMount(() => {
|
||||
portalTarget = document.body;
|
||||
const handleOtherOpen = (event: Event) => {
|
||||
const detail = (event as CustomEvent<symbol>).detail;
|
||||
if (detail !== menuId && isOpen) {
|
||||
closeMenu();
|
||||
// Subscribe to global floating menu store
|
||||
const unsubscribe = subscribeGlobal(() => {
|
||||
const activeId = getActiveMenuId();
|
||||
if (activeId !== null && activeId !== menuId && isOpen) {
|
||||
isOpen = false;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener('qbz-album-menu-open', handleOtherOpen as EventListener);
|
||||
};
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
async function setMenuPosition(retries = 2) {
|
||||
@@ -210,10 +232,40 @@
|
||||
|
||||
window.addEventListener('resize', handleResize);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
|
||||
// Auto-close after inactivity
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (isOpen && !isHoveringMenu) closeMenu();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
const cancelIdleClose = () => {
|
||||
if (idleTimer) {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
if (!isHoveringMenu) scheduleIdleClose();
|
||||
|
||||
const onAnyActivity = () => {
|
||||
if (!isHoveringMenu) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onAnyActivity, true);
|
||||
window.addEventListener('wheel', onAnyActivity, true);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('pointermove', onAnyActivity, true);
|
||||
window.removeEventListener('wheel', onAnyActivity, true);
|
||||
cancelIdleClose();
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -231,14 +283,10 @@
|
||||
bind:this={triggerRef}
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
const nextOpen = !isOpen;
|
||||
isOpen = nextOpen;
|
||||
shareOpen = false;
|
||||
downloadOpen = false;
|
||||
onOpenChange?.(nextOpen);
|
||||
if (nextOpen) {
|
||||
window.dispatchEvent(new CustomEvent('qbz-album-menu-open', { detail: menuId }));
|
||||
setMenuPosition();
|
||||
if (isOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
doOpenMenu();
|
||||
}
|
||||
}}
|
||||
aria-label="Album actions"
|
||||
@@ -248,7 +296,14 @@
|
||||
|
||||
{#if isOpen && portalTarget}
|
||||
<Portal target={portalTarget}>
|
||||
<div class="menu" bind:this={menuEl} style={menuStyle} onmousedown={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
class="menu"
|
||||
bind:this={menuEl}
|
||||
style={menuStyle}
|
||||
onmousedown={(e) => e.stopPropagation()}
|
||||
onmouseenter={() => isHoveringMenu = true}
|
||||
onmouseleave={() => isHoveringMenu = false}
|
||||
>
|
||||
{#if hasQueue}
|
||||
{#if onPlayNext}
|
||||
<button class="menu-item" onclick={() => handleAction(onPlayNext)}>
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown } from 'lucide-svelte';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
closeMenu as closeGlobalMenu,
|
||||
subscribe as subscribeFloatingMenu,
|
||||
getActiveMenuId,
|
||||
MENU_INACTIVITY_TIMEOUT
|
||||
} from '$lib/stores/floatingMenuStore';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
@@ -13,37 +20,91 @@
|
||||
let { value, options, onchange, wide = false, expandLeft = false, compact = false }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let isHovering = $state(false);
|
||||
let dropdownRef: HTMLDivElement;
|
||||
|
||||
// Unique ID for this dropdown instance
|
||||
const menuId = `dropdown-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (dropdownRef && !dropdownRef.contains(event.target as Node)) {
|
||||
isOpen = false;
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
function openDropdown() {
|
||||
openGlobalMenu(menuId);
|
||||
isOpen = true;
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
isOpen = false;
|
||||
closeGlobalMenu(menuId);
|
||||
}
|
||||
|
||||
// Subscribe to global floating menu store
|
||||
$effect(() => {
|
||||
const unsubscribe = subscribeFloatingMenu(() => {
|
||||
const activeId = getActiveMenuId();
|
||||
if (activeId !== null && activeId !== menuId && isOpen) {
|
||||
isOpen = false;
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
|
||||
// Inactivity timeout
|
||||
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('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="dropdown" class:wide bind:this={dropdownRef}>
|
||||
<button class="trigger" onclick={() => (isOpen = !isOpen)}>
|
||||
<button class="trigger" onclick={() => isOpen ? closeDropdown() : openDropdown()}>
|
||||
<span class="value-text">{value}</span>
|
||||
<ChevronDown size={16} class="chevron" />
|
||||
</button>
|
||||
|
||||
{#if isOpen}
|
||||
<div class="menu" class:expand-left={expandLeft} class:compact>
|
||||
<div
|
||||
class="menu"
|
||||
class:expand-left={expandLeft}
|
||||
class:compact
|
||||
onmouseenter={() => isHovering = true}
|
||||
onmouseleave={() => isHovering = false}
|
||||
>
|
||||
{#each options as option}
|
||||
<button
|
||||
class="option"
|
||||
class:selected={option === value}
|
||||
onclick={() => {
|
||||
onchange(option);
|
||||
isOpen = false;
|
||||
closeDropdown();
|
||||
}}
|
||||
title={option}
|
||||
>
|
||||
@@ -170,7 +231,7 @@
|
||||
background-color: rgba(66, 133, 244, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
|
||||
[data-theme="light"] .option.selected,
|
||||
[data-theme="warm"] .option.selected {
|
||||
background-color: rgba(var(--accent-primary), 0.15);
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
movePlaylistToFolder,
|
||||
type PlaylistFolder
|
||||
} from '$lib/stores/playlistFoldersStore';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
closeMenu as closeGlobalMenu,
|
||||
subscribe as subscribeFloatingMenu,
|
||||
getActiveMenuId,
|
||||
MENU_INACTIVITY_TIMEOUT
|
||||
} from '$lib/stores/floatingMenuStore';
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
@@ -137,6 +144,7 @@
|
||||
});
|
||||
|
||||
function showFolderPopover(event: MouseEvent, folder: PlaylistFolder) {
|
||||
openGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
folderPopover = {
|
||||
visible: true,
|
||||
@@ -149,6 +157,7 @@
|
||||
|
||||
function closeFolderPopover() {
|
||||
folderPopover = { ...folderPopover, visible: false, folderId: null };
|
||||
closeGlobalMenu(SIDEBAR_FOLDER_POPOVER_ID);
|
||||
}
|
||||
|
||||
// Filtered folders for context menu
|
||||
@@ -175,6 +184,14 @@
|
||||
let submenuEl: HTMLDivElement | null = null;
|
||||
let menuStyle = $state('');
|
||||
let submenuStyle = $state('');
|
||||
let isHoveringDropdown = $state(false);
|
||||
let isHoveringContextMenu = $state(false);
|
||||
let isHoveringFolderPopover = $state(false);
|
||||
|
||||
// Unique IDs for global floating menu store
|
||||
const SIDEBAR_DROPDOWN_ID = 'sidebar-dropdown';
|
||||
const SIDEBAR_CONTEXT_MENU_ID = 'sidebar-context-menu';
|
||||
const SIDEBAR_FOLDER_POPOVER_ID = 'sidebar-folder-popover';
|
||||
|
||||
function openSubmenu() {
|
||||
if (submenuCloseTimeout) {
|
||||
@@ -420,6 +437,7 @@
|
||||
clearTimeout(submenuCloseTimeout);
|
||||
submenuCloseTimeout = null;
|
||||
}
|
||||
closeGlobalMenu(SIDEBAR_DROPDOWN_ID);
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
@@ -481,6 +499,7 @@
|
||||
if (menuOpen) {
|
||||
closeMenu();
|
||||
} else {
|
||||
openGlobalMenu(SIDEBAR_DROPDOWN_ID);
|
||||
menuOpen = true;
|
||||
await setMenuPosition();
|
||||
document.addEventListener('click', handleClickOutside);
|
||||
@@ -540,6 +559,114 @@
|
||||
// Check if any playlists exist in root (no folder)
|
||||
let rootPlaylists = $derived(getPlaylistsInFolder(null));
|
||||
|
||||
// Subscribe to global floating menu store
|
||||
$effect(() => {
|
||||
const unsubscribe = subscribeFloatingMenu(() => {
|
||||
const activeId = getActiveMenuId();
|
||||
// Close dropdown if another menu is active
|
||||
if (activeId !== null && activeId !== SIDEBAR_DROPDOWN_ID && menuOpen) {
|
||||
menuOpen = false;
|
||||
sortSubmenuOpen = false;
|
||||
if (submenuCloseTimeout) {
|
||||
clearTimeout(submenuCloseTimeout);
|
||||
submenuCloseTimeout = null;
|
||||
}
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
}
|
||||
// Close context menu if another menu is active
|
||||
if (activeId !== null && activeId !== SIDEBAR_CONTEXT_MENU_ID && contextMenu.visible) {
|
||||
contextMenu = { ...contextMenu, visible: false };
|
||||
contextMenuSearch = '';
|
||||
}
|
||||
// Close folder popover if another menu is active
|
||||
if (activeId !== null && activeId !== SIDEBAR_FOLDER_POPOVER_ID && folderPopover.visible) {
|
||||
folderPopover = { ...folderPopover, visible: false, folderId: null };
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
// Inactivity timeout for dropdown menu
|
||||
$effect(() => {
|
||||
if (menuOpen) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (menuOpen && !isHoveringDropdown) closeMenu();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
if (!isHoveringDropdown) scheduleIdleClose();
|
||||
|
||||
const onActivity = () => {
|
||||
if (!isHoveringDropdown) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onActivity, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Inactivity timeout for context menu
|
||||
$effect(() => {
|
||||
if (contextMenu.visible) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (contextMenu.visible && !isHoveringContextMenu) closeContextMenu();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
if (!isHoveringContextMenu) scheduleIdleClose();
|
||||
|
||||
const onActivity = () => {
|
||||
if (!isHoveringContextMenu) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onActivity, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Inactivity timeout for folder popover
|
||||
$effect(() => {
|
||||
if (folderPopover.visible) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (folderPopover.visible && !isHoveringFolderPopover) closeFolderPopover();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
if (!isHoveringFolderPopover) scheduleIdleClose();
|
||||
|
||||
const onActivity = () => {
|
||||
if (!isHoveringFolderPopover) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onActivity, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!menuOpen) {
|
||||
document.removeEventListener('click', handleClickOutside);
|
||||
@@ -680,6 +807,8 @@
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
openGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
|
||||
|
||||
// Get the current folder_id from settings
|
||||
const settings = playlistSettings.get(playlist.id);
|
||||
const currentFolderId = folderId ?? settings?.folder_id ?? null;
|
||||
@@ -696,6 +825,7 @@
|
||||
function closeContextMenu() {
|
||||
contextMenu = { ...contextMenu, visible: false };
|
||||
contextMenuSearch = '';
|
||||
closeGlobalMenu(SIDEBAR_CONTEXT_MENU_ID);
|
||||
}
|
||||
|
||||
async function handleMoveToFolder(folderId: string | null) {
|
||||
@@ -812,7 +942,13 @@
|
||||
|
||||
<!-- Dropdown Menu -->
|
||||
{#if menuOpen}
|
||||
<div class="dropdown-menu" bind:this={menuEl} style={menuStyle}>
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
bind:this={menuEl}
|
||||
style={menuStyle}
|
||||
onmouseenter={() => isHoveringDropdown = true}
|
||||
onmouseleave={() => isHoveringDropdown = false}
|
||||
>
|
||||
<!-- Sort by submenu trigger -->
|
||||
<div
|
||||
class="menu-item has-submenu"
|
||||
@@ -1038,6 +1174,8 @@
|
||||
class:has-search={showSearch}
|
||||
style="left: {contextMenu.x}px; top: {contextMenu.y}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onmouseenter={() => isHoveringContextMenu = true}
|
||||
onmouseleave={() => isHoveringContextMenu = false}
|
||||
role="menu"
|
||||
>
|
||||
{#if availableFolders.length > 0}
|
||||
@@ -1095,6 +1233,8 @@
|
||||
class="folder-popover"
|
||||
style="left: {folderPopover.x}px; top: {folderPopover.y}px;"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onmouseenter={() => isHoveringFolderPopover = true}
|
||||
onmouseleave={() => isHoveringFolderPopover = false}
|
||||
role="menu"
|
||||
>
|
||||
<div class="folder-popover-header">
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
setActiveTrackMenuId,
|
||||
subscribeActiveTrackMenuId
|
||||
} from '$lib/stores/activeTrackMenu';
|
||||
import { MENU_INACTIVITY_TIMEOUT } from '$lib/stores/floatingMenuStore';
|
||||
|
||||
interface Props {
|
||||
onPlayNow?: () => void;
|
||||
@@ -265,7 +266,7 @@
|
||||
|
||||
// Auto-close after inactivity when the cursor is not hovering any menu/submenu.
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const idleMs = 5_000;
|
||||
const idleMs = MENU_INACTIVITY_TIMEOUT;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
|
||||
@@ -24,6 +24,13 @@
|
||||
movePlaylistToFolder,
|
||||
type PlaylistFolder
|
||||
} from '$lib/stores/playlistFoldersStore';
|
||||
import {
|
||||
openMenu as openGlobalMenu,
|
||||
closeMenu as closeGlobalMenu,
|
||||
subscribe as subscribeFloatingMenu,
|
||||
getActiveMenuId,
|
||||
MENU_INACTIVITY_TIMEOUT
|
||||
} from '$lib/stores/floatingMenuStore';
|
||||
|
||||
interface Playlist {
|
||||
id: number;
|
||||
@@ -91,6 +98,12 @@
|
||||
// Dropdown state
|
||||
let showFilterMenu = $state(false);
|
||||
let showSortMenu = $state(false);
|
||||
let isHoveringFilterMenu = $state(false);
|
||||
let isHoveringSortMenu = $state(false);
|
||||
|
||||
// Unique IDs for global floating menu store
|
||||
const PM_FILTER_MENU_ID = 'playlist-manager-filter';
|
||||
const PM_SORT_MENU_ID = 'playlist-manager-sort';
|
||||
|
||||
// Edit modal state
|
||||
let editModalOpen = $state(false);
|
||||
@@ -117,6 +130,85 @@
|
||||
$effect(() => { localStorage.setItem('qbz-pm-sort', sort); });
|
||||
$effect(() => { localStorage.setItem('qbz-pm-view', viewMode); });
|
||||
|
||||
// Helper functions for closing menus with global store
|
||||
function closeFilterMenu() {
|
||||
showFilterMenu = false;
|
||||
closeGlobalMenu(PM_FILTER_MENU_ID);
|
||||
}
|
||||
|
||||
function closeSortMenu() {
|
||||
showSortMenu = false;
|
||||
closeGlobalMenu(PM_SORT_MENU_ID);
|
||||
}
|
||||
|
||||
// Subscribe to global floating menu store
|
||||
$effect(() => {
|
||||
const unsubscribe = subscribeFloatingMenu(() => {
|
||||
const activeId = getActiveMenuId();
|
||||
if (activeId !== null && activeId !== PM_FILTER_MENU_ID && showFilterMenu) {
|
||||
showFilterMenu = false;
|
||||
}
|
||||
if (activeId !== null && activeId !== PM_SORT_MENU_ID && showSortMenu) {
|
||||
showSortMenu = false;
|
||||
}
|
||||
});
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
// Inactivity timeout for filter menu
|
||||
$effect(() => {
|
||||
if (showFilterMenu) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (showFilterMenu && !isHoveringFilterMenu) closeFilterMenu();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
if (!isHoveringFilterMenu) scheduleIdleClose();
|
||||
|
||||
const onActivity = () => {
|
||||
if (!isHoveringFilterMenu) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onActivity, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Inactivity timeout for sort menu
|
||||
$effect(() => {
|
||||
if (showSortMenu) {
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const scheduleIdleClose = () => {
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (showSortMenu && !isHoveringSortMenu) closeSortMenu();
|
||||
}, MENU_INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
if (!isHoveringSortMenu) scheduleIdleClose();
|
||||
|
||||
const onActivity = () => {
|
||||
if (!isHoveringSortMenu) scheduleIdleClose();
|
||||
};
|
||||
|
||||
window.addEventListener('pointermove', onActivity, true);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onActivity, true);
|
||||
if (idleTimer) clearTimeout(idleTimer);
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get local content status for a playlist (calculated from actual data)
|
||||
function getLocalContentStatus(playlistId: number): LocalContentStatus {
|
||||
const playlist = playlists.find(p => p.id === playlistId);
|
||||
@@ -699,7 +791,16 @@
|
||||
|
||||
<!-- Filter dropdown -->
|
||||
<div class="dropdown-container">
|
||||
<button class="control-btn" onclick={() => { showFilterMenu = !showFilterMenu; showSortMenu = false; }}>
|
||||
<button class="control-btn" onclick={() => {
|
||||
if (showFilterMenu) {
|
||||
showFilterMenu = false;
|
||||
closeGlobalMenu(PM_FILTER_MENU_ID);
|
||||
} else {
|
||||
showSortMenu = false;
|
||||
openGlobalMenu(PM_FILTER_MENU_ID);
|
||||
showFilterMenu = true;
|
||||
}
|
||||
}}>
|
||||
{#if filter === 'hidden'}
|
||||
<EyeOff size={16} />
|
||||
{:else if filter === 'offline_unavailable'}
|
||||
@@ -716,26 +817,30 @@
|
||||
</span>
|
||||
</button>
|
||||
{#if showFilterMenu}
|
||||
<div class="dropdown-menu">
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
onmouseenter={() => isHoveringFilterMenu = true}
|
||||
onmouseleave={() => isHoveringFilterMenu = false}
|
||||
>
|
||||
{#if offlineStatus.isOffline}
|
||||
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'all' || filter === 'offline_all'} onclick={() => { filter = 'offline_all'; closeFilterMenu(); }}>
|
||||
{$t('offline.available')}
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'offline_partial'} onclick={() => { filter = 'offline_partial'; closeFilterMenu(); }}>
|
||||
{$t('offline.partiallyAvailable')}
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'offline_unavailable'} onclick={() => { filter = 'offline_unavailable'; closeFilterMenu(); }}>
|
||||
{$t('offline.notAvailableOffline')}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
{/if}
|
||||
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'all' && !offlineStatus.isOffline} onclick={() => { filter = 'all'; closeFilterMenu(); }}>
|
||||
{offlineStatus.isOffline ? $t('filter.all') : 'All'}
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'visible'} onclick={() => { filter = 'visible'; closeFilterMenu(); }}>
|
||||
Visible
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; showFilterMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={filter === 'hidden'} onclick={() => { filter = 'hidden'; closeFilterMenu(); }}>
|
||||
Hidden
|
||||
</button>
|
||||
</div>
|
||||
@@ -744,27 +849,40 @@
|
||||
|
||||
<!-- Sort dropdown -->
|
||||
<div class="dropdown-container">
|
||||
<button class="control-btn" onclick={() => { showSortMenu = !showSortMenu; showFilterMenu = false; }}>
|
||||
<button class="control-btn" onclick={() => {
|
||||
if (showSortMenu) {
|
||||
showSortMenu = false;
|
||||
closeGlobalMenu(PM_SORT_MENU_ID);
|
||||
} else {
|
||||
showFilterMenu = false;
|
||||
openGlobalMenu(PM_SORT_MENU_ID);
|
||||
showSortMenu = true;
|
||||
}
|
||||
}}>
|
||||
<ArrowUpDown size={16} />
|
||||
<span>
|
||||
{sort === 'name' ? 'Name' : sort === 'recent' ? 'Recent' : sort === 'playcount' ? 'Play Count' : sort === 'tracks' ? 'Track Count' : 'Custom'}
|
||||
</span>
|
||||
</button>
|
||||
{#if showSortMenu}
|
||||
<div class="dropdown-menu">
|
||||
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; showSortMenu = false; }}>
|
||||
<div
|
||||
class="dropdown-menu"
|
||||
onmouseenter={() => isHoveringSortMenu = true}
|
||||
onmouseleave={() => isHoveringSortMenu = false}
|
||||
>
|
||||
<button class="dropdown-item" class:selected={sort === 'name'} onclick={() => { sort = 'name'; closeSortMenu(); }}>
|
||||
Name (A-Z)
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; showSortMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={sort === 'recent'} onclick={() => { sort = 'recent'; closeSortMenu(); }}>
|
||||
Recent
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; showSortMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={sort === 'playcount'} onclick={() => { sort = 'playcount'; closeSortMenu(); }}>
|
||||
Play Count
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; showSortMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={sort === 'tracks'} onclick={() => { sort = 'tracks'; closeSortMenu(); }}>
|
||||
Track Count
|
||||
</button>
|
||||
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; showSortMenu = false; }}>
|
||||
<button class="dropdown-item" class:selected={sort === 'custom'} onclick={() => { sort = 'custom'; closeSortMenu(); }}>
|
||||
Custom Order
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { openMenu as openGlobalMenu, closeMenu as closeGlobalMenu, subscribe as subscribeGlobal, getActiveMenuId } from './floatingMenuStore';
|
||||
|
||||
type Listener = (menuId: number | null) => void;
|
||||
|
||||
let activeMenuId: number | null = null;
|
||||
const listeners = new Set<Listener>();
|
||||
let nextMenuId = 1;
|
||||
|
||||
// Generate global menu ID for track menus
|
||||
function getGlobalMenuId(localId: number): string {
|
||||
return `track-menu-${localId}`;
|
||||
}
|
||||
|
||||
export function allocateTrackMenuId(): number {
|
||||
// Monotonic id to ensure only one TrackMenu instance matches the active id at a time.
|
||||
return nextMenuId++;
|
||||
@@ -15,7 +22,19 @@ export function getActiveTrackMenuId(): number | null {
|
||||
|
||||
export function setActiveTrackMenuId(menuId: number | null): void {
|
||||
if (activeMenuId === menuId) return;
|
||||
|
||||
// Close previous in global store
|
||||
if (activeMenuId !== null) {
|
||||
closeGlobalMenu(getGlobalMenuId(activeMenuId));
|
||||
}
|
||||
|
||||
activeMenuId = menuId;
|
||||
|
||||
// Open new in global store
|
||||
if (menuId !== null) {
|
||||
openGlobalMenu(getGlobalMenuId(menuId));
|
||||
}
|
||||
|
||||
for (const listener of listeners) listener(activeMenuId);
|
||||
}
|
||||
|
||||
@@ -26,3 +45,15 @@ export function subscribeActiveTrackMenuId(listener: Listener): () => void {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
// Subscribe to global store to close track menus when other menus open
|
||||
subscribeGlobal(() => {
|
||||
const globalActive = getActiveMenuId();
|
||||
// If global active menu is not a track menu, close any open track menu
|
||||
if (globalActive !== null && !globalActive.startsWith('track-menu-')) {
|
||||
if (activeMenuId !== null) {
|
||||
activeMenuId = null;
|
||||
for (const listener of listeners) listener(null);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Global Floating Menu Store
|
||||
*
|
||||
* Ensures only one floating menu can be open at a time across the entire app.
|
||||
* All floating menus (context menus, dropdowns, popovers) should use this store.
|
||||
*/
|
||||
|
||||
type Subscriber = () => void;
|
||||
|
||||
let activeMenuId: string | null = null;
|
||||
const subscribers = new Set<Subscriber>();
|
||||
|
||||
function notify() {
|
||||
subscribers.forEach(fn => fn());
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to store changes
|
||||
*/
|
||||
export function subscribe(fn: Subscriber): () => void {
|
||||
subscribers.add(fn);
|
||||
return () => subscribers.delete(fn);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the currently active menu ID
|
||||
*/
|
||||
export function getActiveMenuId(): string | null {
|
||||
return activeMenuId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a specific menu is currently active
|
||||
*/
|
||||
export function isMenuActive(menuId: string): boolean {
|
||||
return activeMenuId === menuId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a menu (closes any other open menu first)
|
||||
*/
|
||||
export function openMenu(menuId: string): void {
|
||||
if (activeMenuId !== menuId) {
|
||||
activeMenuId = menuId;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific menu (only if it's the active one)
|
||||
*/
|
||||
export function closeMenu(menuId: string): void {
|
||||
if (activeMenuId === menuId) {
|
||||
activeMenuId = null;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close any open menu
|
||||
*/
|
||||
export function closeAll(): void {
|
||||
if (activeMenuId !== null) {
|
||||
activeMenuId = null;
|
||||
notify();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard inactivity timeout for floating menus (in milliseconds)
|
||||
*/
|
||||
export const MENU_INACTIVITY_TIMEOUT = 2000;
|
||||
Reference in new issue
Block a user