a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1// context menu
2
3let contextMenuEl = null;
4let currentKeyboardHandler = null;
5let currentClickHandler = null;
6let currentSystemContextmenuEventHandler = null;
7
8// remove context menu display and event listeners
9function removeContextMenuDisplay() {
10 if (!contextMenuEl) return;
11 contextMenuEl.remove();
12 contextMenuEl = null;
13
14 if (currentKeyboardHandler) {
15 document.removeEventListener("keydown", currentKeyboardHandler);
16 currentKeyboardHandler = null;
17 }
18
19 if (currentClickHandler) {
20 document.removeEventListener("click", currentClickHandler, {
21 capture: true,
22 });
23 currentClickHandler = null;
24 }
25
26 if (currentSystemContextmenuEventHandler) {
27 document.removeEventListener(
28 "contextmenu",
29 currentSystemContextmenuEventHandler,
30 {
31 capture: true,
32 },
33 );
34 currentSystemContextmenuEventHandler = null;
35 }
36}
37
38// remove context menu and cleanup all listeners
39function cleanupContextMenu() {
40 if (!contextMenuEl) return;
41
42 removeContextMenuDisplay();
43
44 // restore focus to main immediately
45 // NOTE: if we ever add context menus to library items, we'll want to make this dynamic but i think this is ok for now
46 getMainEl().focus();
47}
48
49// display context menu with given items at position
50function showContextMenu(x, y, items) {
51 // close any existing menu
52 removeContextMenuDisplay();
53
54 contextMenuEl = createElement("div", {
55 attributes: { id: DOM_IDS.CONTEXT_MENU, role: "menu" },
56 });
57
58 const menuItems = [];
59 Object.entries(items).forEach(([label, handler]) => {
60 const item = createElement("button", {
61 className: CLASSES.CONTEXT_MENU_ITEM,
62 textContent: label,
63 attributes: { role: "menuitem", tabindex: "-1" },
64 listeners: {
65 mousedown: (e) => e.preventDefault(), // prevent focus on mousedown
66 click: (e) => {
67 e.stopPropagation();
68 handler();
69 },
70 },
71 });
72 menuItems.push(item);
73 contextMenuEl.appendChild(item);
74 });
75
76 // append to main element to keep focus within main
77 // NOTE: if we ever add context menus to library items, we'll want to make this dynamic but i think this is ok for now
78 getMainEl().appendChild(contextMenuEl);
79
80 // position menu with bounds checking to keep within viewport
81 const rect = contextMenuEl.getBoundingClientRect();
82 const clampPosition = (pos, size, limit) =>
83 Math.max(0, Math.min(pos, limit - size));
84 contextMenuEl.style.left = `${clampPosition(x, rect.width, window.innerWidth)}px`;
85 contextMenuEl.style.top = `${clampPosition(y, rect.height, window.innerHeight)}px`;
86
87 // keyboard navigation for context menu
88 let focusedIndex = 0;
89
90 const updateMenuItemFocus = () => {
91 const focused = contextMenuEl.querySelector(".focused");
92 if (focused) focused.classList.remove("focused");
93 menuItems[focusedIndex].classList.add("focused");
94 };
95
96 currentKeyboardHandler = (e) => {
97 if (!contextMenuEl) return; // menu was closed
98 if (!["ArrowUp", "ArrowDown", "Enter", "Space", "Escape"].includes(e.code))
99 return;
100
101 e.preventDefault();
102 e.stopPropagation();
103
104 switch (e.code) {
105 case "ArrowUp":
106 focusedIndex = (focusedIndex - 1 + menuItems.length) % menuItems.length;
107 updateMenuItemFocus();
108 break;
109
110 case "ArrowDown":
111 focusedIndex = (focusedIndex + 1) % menuItems.length;
112 updateMenuItemFocus();
113 break;
114
115 case "Enter":
116 case "Space":
117 menuItems[focusedIndex].click();
118 break;
119
120 case "Escape":
121 cleanupContextMenu();
122 break;
123 }
124 };
125
126 document.addEventListener("keydown", currentKeyboardHandler);
127
128 // close menu on clicks outside it
129 currentClickHandler = (e) => {
130 if (!contextMenuEl?.contains(e.target)) {
131 cleanupContextMenu();
132 }
133 };
134 document.addEventListener("click", currentClickHandler, { capture: true });
135
136 // prevent default browser context menu from appearing
137 currentSystemContextmenuEventHandler = (e) => {
138 if (
139 !contextMenuEl?.contains(e.target) &&
140 !e.target.closest(`#${DOM_IDS.QUEUE_LIST}`)
141 ) {
142 e.preventDefault();
143 e.stopImmediatePropagation();
144 }
145 };
146 document.addEventListener(
147 "contextmenu",
148 currentSystemContextmenuEventHandler,
149 {
150 capture: true,
151 },
152 );
153}
154
155// wrap handler to auto-cleanup
156const withContextMenuCleanup =
157 (handler) =>
158 async (...args) => {
159 try {
160 await handler(...args);
161 } finally {
162 cleanupContextMenu();
163 }
164 };
165
166// get sort menu items
167function getSortMenuItems() {
168 return {
169 [STRINGS.CONTEXT_SORT_SHUFFLE]: withContextMenuCleanup(() => {
170 sortQueue("shuffle");
171 updateQueue();
172 }),
173 [STRINGS.CONTEXT_SORT_SONG_AZ]: withContextMenuCleanup(() => {
174 sortQueue("title", true);
175 updateQueue();
176 }),
177 [STRINGS.CONTEXT_SORT_SONG_ZA]: withContextMenuCleanup(() => {
178 sortQueue("title", false);
179 updateQueue();
180 }),
181 [STRINGS.CONTEXT_SORT_ARTIST_AZ]: withContextMenuCleanup(() => {
182 sortQueue("artist", true);
183 updateQueue();
184 }),
185 [STRINGS.CONTEXT_SORT_ARTIST_ZA]: withContextMenuCleanup(() => {
186 sortQueue("artist", false);
187 updateQueue();
188 }),
189 [STRINGS.CONTEXT_SORT_ALBUM_AZ]: withContextMenuCleanup(() => {
190 sortQueue("album", true);
191 updateQueue();
192 }),
193 [STRINGS.CONTEXT_SORT_ALBUM_ZA]: withContextMenuCleanup(() => {
194 sortQueue("album", false);
195 updateQueue();
196 }),
197 [STRINGS.CONTEXT_SORT_DURATION_SHORT_LONG]: withContextMenuCleanup(() => {
198 sortQueue("duration", true);
199 updateQueue();
200 }),
201 [STRINGS.CONTEXT_SORT_DURATION_LONG_SHORT]: withContextMenuCleanup(() => {
202 sortQueue("duration", false);
203 updateQueue();
204 }),
205 [STRINGS.CONTEXT_SORT_FAVORITED_FIRST]: withContextMenuCleanup(() => {
206 sortQueue("favorited", false);
207 updateQueue();
208 }),
209 [STRINGS.CONTEXT_SORT_FAVORITED_LAST]: withContextMenuCleanup(() => {
210 sortQueue("favorited", true);
211 updateQueue();
212 }),
213 };
214}
215
216// show context menu
217function showQueueContextMenuAtSelection(x, y, selectedIndices) {
218 showContextMenu(x, y, {
219 [STRINGS.CONTEXT_PLAY]: withContextMenuCleanup(() => {
220 playQueueTrack(selectedIndices[0]);
221 updateQueue();
222 }),
223 [STRINGS.CONTEXT_PLAY_NEXT]: withContextMenuCleanup(() => {
224 const insertPos = state.queueIndex >= 0 ? state.queueIndex + 1 : 0;
225 moveQueueItems(state.queue, selectedIndices, insertPos, queueCallbacks);
226 }),
227 [STRINGS.CONTEXT_SORT]: () => {
228 showContextMenu(x, y, getSortMenuItems());
229 },
230 [STRINGS.CONTEXT_FAVORITE]: withContextMenuCleanup(async () => {
231 await Promise.all(
232 selectedIndices.map((i) => setFavoriteSong(state.queue[i], true)),
233 );
234 updateQueueDisplay();
235 }),
236 [STRINGS.CONTEXT_UNFAVORITE]: withContextMenuCleanup(async () => {
237 await Promise.all(
238 selectedIndices.map((i) => setFavoriteSong(state.queue[i], false)),
239 );
240 updateQueueDisplay();
241 }),
242 [STRINGS.CONTEXT_MOVE_UP]: withContextMenuCleanup(() => {
243 const firstIdx = Math.min(...selectedIndices);
244 if (firstIdx > 0) {
245 moveQueueItems(
246 state.queue,
247 selectedIndices,
248 firstIdx - 1,
249 queueCallbacks,
250 );
251 }
252 }),
253 [STRINGS.CONTEXT_MOVE_DOWN]: withContextMenuCleanup(() => {
254 const lastIdx = Math.max(...selectedIndices);
255 if (lastIdx < state.queue.length - 1) {
256 moveQueueItems(
257 state.queue,
258 selectedIndices,
259 lastIdx + 2,
260 queueCallbacks,
261 );
262 }
263 }),
264 [STRINGS.CONTEXT_CLEAR]: withContextMenuCleanup(() => {
265 clearSelectedRows();
266 }),
267 });
268}
269
270function setupQueueContextMenu() {
271 ui.queueList.addEventListener(
272 "contextmenu",
273 (e) => {
274 const row = getClosestRow(e.target);
275 if (!row) return;
276
277 e.preventDefault();
278 e.stopPropagation();
279
280 const idx = getRowIndex(row, DATA_ATTRS.INDEX);
281 if (!selectionManager.isSelected(idx)) {
282 selectionManager.select(idx);
283 }
284
285 const selectedIndices = Array.from(selectionManager.getSelected());
286 showQueueContextMenuAtSelection(e.clientX, e.clientY, selectedIndices);
287 },
288 true,
289 );
290}