a simple web player for subsonic
tinysub.devins.page
subsonic
navidrome
javascript
1<script lang="ts">
2 import { onMount } from "svelte";
3
4 type Item = {
5 label?: string;
6 action?: () => void;
7 items?: Item[];
8 disabled?: boolean;
9 type?: "separator";
10 };
11
12 let { x, y, items, onclose } = $props<{
13 x: number;
14 y: number;
15 items: Item[];
16 onclose: () => void;
17 }>();
18
19 let active = $state<number | null>(null),
20 subActive = $state<number | null>(null),
21 w = $state(0),
22 h = $state(0),
23 el = $state<HTMLElement>();
24
25 onMount(() => {
26 const prevFocus = document.activeElement as HTMLElement | null;
27 el?.focus();
28 return () => prevFocus?.focus();
29 });
30
31 const exec = (a?: () => void) => {
32 if (a) {
33 a();
34 onclose();
35 }
36 };
37
38 const left = $derived(x + w > window.innerWidth ? Math.max(0, x - w) : x);
39 const top = $derived(y + h > window.innerHeight ? Math.max(0, y - h) : y);
40
41 function onKey(e: KeyboardEvent) {
42 e.stopPropagation();
43 const k = e.key;
44 if (k === "Escape") {
45 onclose();
46 } else if (k === "ArrowDown") {
47 e.preventDefault();
48 if (subActive !== null && active !== null && items[active].items) {
49 const s = items[active].items!;
50 let next = (subActive + 1) % s.length;
51 while (s[next]?.type === "separator") next = (next + 1) % s.length;
52 subActive = next;
53 } else {
54 let next = active === null ? 0 : (active + 1) % items.length;
55 while (items[next]?.type === "separator")
56 next = (next + 1) % items.length;
57 active = next;
58 subActive = null;
59 }
60 } else if (k === "ArrowUp") {
61 e.preventDefault();
62 if (subActive !== null && active !== null && items[active].items) {
63 const s = items[active].items!;
64 let next = (subActive - 1 + s.length) % s.length;
65 while (s[next]?.type === "separator")
66 next = (next - 1 + s.length) % s.length;
67 subActive = next;
68 } else {
69 let next =
70 active === null
71 ? items.length - 1
72 : (active - 1 + items.length) % items.length;
73 while (items[next]?.type === "separator")
74 next = (next - 1 + items.length) % items.length;
75 active = next;
76 subActive = null;
77 }
78 } else if (k === "ArrowRight") {
79 if (active !== null && items[active].items) {
80 e.preventDefault();
81 subActive = 0;
82 }
83 } else if (k === "ArrowLeft") {
84 if (subActive !== null) {
85 e.preventDefault();
86 subActive = null;
87 }
88 } else if (k === "Enter") {
89 e.preventDefault();
90 if (active !== null) {
91 const item = items[active];
92 if (item.items) {
93 if (subActive === null) subActive = 0;
94 else exec(item.items[subActive].action);
95 } else exec(item.action);
96 }
97 }
98 }
99</script>
100
101{#snippet menuButton(item: Item, isActive: boolean, isSub: boolean)}
102 <button
103 class:active={isActive}
104 disabled={item.disabled}
105 onmouseenter={() => {
106 if (isSub) {
107 if (active !== null && items[active].items) {
108 subActive = items[active].items.indexOf(item);
109 }
110 } else {
111 active = items.indexOf(item);
112 subActive = null;
113 }
114 }}
115 onclick={(e) => {
116 e.stopPropagation();
117 if (item.items) {
118 active = items.indexOf(item);
119 subActive = null;
120 } else {
121 exec(item.action);
122 }
123 }}
124 >
125 {item.label}
126 {#if item.items}<span class="arrow">▶</span>{/if}
127 </button>
128{/snippet}
129
130<!-- svelte-ignore a11y_no_static_element_interactions -->
131<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
132<!-- svelte-ignore a11y_click_events_have_key_events -->
133<div
134 class="overlay"
135 onclick={onclose}
136 oncontextmenu={(e) => {
137 e.preventDefault();
138 onclose();
139 }}
140>
141 <div
142 bind:this={el}
143 class="menu"
144 style="top: {top}px; left: {left}px;"
145 onclick={(e) => e.stopPropagation()}
146 onkeydown={onKey}
147 bind:clientWidth={w}
148 bind:clientHeight={h}
149 tabindex="0"
150 >
151 {#each items as item, i}
152 {#if item.type === "separator"}
153 <div class="separator"></div>
154 {:else}
155 <div
156 class="row"
157 onmouseleave={() => {
158 if (subActive === null) active = null;
159 }}
160 >
161 {@render menuButton(item, active === i && subActive === null, false)}
162 {#if item.items && active === i}
163 <div
164 class="submenu"
165 class:flip-x={left + w + 160 > window.innerWidth}
166 class:flip-y={top + i * 32 + item.items.length * 32 >
167 window.innerHeight}
168 >
169 {#each item.items as sub, si}
170 {#if sub.type === "separator"}
171 <div class="separator"></div>
172 {:else}
173 {@render menuButton(sub, subActive === si, true)}
174 {/if}
175 {/each}
176 </div>
177 {/if}
178 </div>
179 {/if}
180 {/each}
181 </div>
182</div>
183
184<style>
185 .overlay {
186 position: fixed;
187 inset: 0;
188 z-index: 1000;
189 }
190 .menu {
191 position: absolute;
192 background: Canvas;
193 color: CanvasText;
194 border: 1px solid var(--border);
195 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
196 min-inline-size: 160px;
197 display: flex;
198 flex-direction: column;
199 outline: none;
200 }
201 .row {
202 position: relative;
203 }
204 button {
205 background: none;
206 border: none;
207 text-align: left;
208 padding: 0.375rem 0.75rem;
209 inline-size: 100%;
210 display: flex;
211 justify-content: space-between;
212 align-items: center;
213 white-space: nowrap;
214 gap: 1rem;
215 color: inherit;
216 }
217 button:hover,
218 button.active {
219 background: Highlight;
220 color: HighlightText;
221 }
222 button:disabled {
223 opacity: 0.5;
224 }
225 .separator {
226 border-block-start: 1px solid var(--border-subtle);
227 }
228 .arrow {
229 font-size: 0.7em;
230 opacity: 0.5;
231 }
232 .submenu {
233 position: absolute;
234 inset-inline-start: 100%;
235 inset-block-start: 0;
236 background: Canvas;
237 border: 1px solid var(--border);
238 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.2);
239 min-inline-size: 160px;
240 display: flex;
241 flex-direction: column;
242 }
243 .submenu.flip-x {
244 inset-inline-start: auto;
245 inset-inline-end: 100%;
246 }
247 .submenu.flip-y {
248 inset-block-start: auto;
249 inset-block-end: 0;
250 }
251</style>