a simple web player for subsonic tinysub.devins.page
subsonic navidrome javascript
11
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: copy/cut/paste for queue

+168 -79
+48 -25
src/lib/ContextMenu.svelte
··· 2 2 import { onMount } from "svelte"; 3 3 4 4 type Item = { 5 - label: string; 5 + label?: string; 6 6 action?: () => void; 7 7 items?: Item[]; 8 8 disabled?: boolean; 9 + type?: "separator"; 9 10 }; 10 11 11 12 let { x, y, items, onclose } = $props<{ ··· 46 47 e.preventDefault(); 47 48 if (subActive !== null && active !== null && items[active].items) { 48 49 const s = items[active].items!; 49 - subActive = (subActive + 1) % s.length; 50 + let next = (subActive + 1) % s.length; 51 + while (s[next]?.type === "separator") next = (next + 1) % s.length; 52 + subActive = next; 50 53 } else { 51 - active = active === null ? 0 : (active + 1) % items.length; 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; 52 58 subActive = null; 53 59 } 54 60 } else if (k === "ArrowUp") { 55 61 e.preventDefault(); 56 62 if (subActive !== null && active !== null && items[active].items) { 57 63 const s = items[active].items!; 58 - subActive = (subActive - 1 + s.length) % s.length; 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; 59 68 } else { 60 - active = 69 + let next = 61 70 active === null 62 71 ? items.length - 1 63 72 : (active - 1 + items.length) % items.length; 73 + while (items[next]?.type === "separator") 74 + next = (next - 1 + items.length) % items.length; 75 + active = next; 64 76 subActive = null; 65 77 } 66 78 } else if (k === "ArrowRight") { ··· 137 149 tabindex="0" 138 150 > 139 151 {#each items as item, i} 140 - <div 141 - class="row" 142 - onmouseleave={() => { 143 - if (subActive === null) active = null; 144 - }} 145 - > 146 - {@render menuButton(item, active === i && subActive === null, false)} 147 - {#if item.items && active === i} 148 - <div 149 - class="submenu" 150 - class:flip-x={left + w + 160 > window.innerWidth} 151 - class:flip-y={top + i * 32 + item.items.length * 32 > 152 - window.innerHeight} 153 - > 154 - {#each item.items as sub, si} 155 - {@render menuButton(sub, subActive === si, true)} 156 - {/each} 157 - </div> 158 - {/if} 159 - </div> 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} 160 180 {/each} 161 181 </div> 162 182 </div> ··· 201 221 } 202 222 button:disabled { 203 223 opacity: 0.5; 224 + } 225 + .separator { 226 + border-block-start: 1px solid var(--border-subtle); 204 227 } 205 228 .arrow { 206 229 font-size: 0.7em;
+69 -25
src/lib/Queue.svelte
··· 17 17 rateSelected, 18 18 selectAll, 19 19 moveSelected, 20 + copy, 21 + cut, 22 + paste, 23 + clear, 20 24 nav, 21 25 getSortItems, 22 26 t, ··· 75 79 if (key === "a") { 76 80 e.preventDefault(); 77 81 selectAll(); 82 + } else if (key === "c") { 83 + e.preventDefault(); 84 + copy(); 85 + } else if (key === "x") { 86 + e.preventDefault(); 87 + cut(); 88 + } else if (key === "v") { 89 + e.preventDefault(); 90 + paste(); 78 91 } 79 92 return; 80 93 } ··· 102 115 } 103 116 104 117 function showMenu(index: number, x?: number, y?: number) { 105 - if (index < 0) return; 106 - if (!queue.sel.includes(index)) select(index); 107 - const row = document.querySelector(`[data-id="queue-row-${index}"]`); 118 + if (index >= 0 && !queue.sel.includes(index)) select(index); 119 + const row = 120 + index >= 0 121 + ? document.querySelector(`[data-id="queue-row-${index}"]`) 122 + : null; 108 123 menu = { 109 124 x: x ?? window.innerWidth / 2, 110 125 y: y ?? row?.getBoundingClientRect().bottom ?? window.innerHeight / 2, ··· 122 137 onclick={clearSel} 123 138 onkeydown={onKey} 124 139 onfocusin={() => (ui.activeContext = "queue")} 140 + oncontextmenu={(e) => { 141 + if (e.target === e.currentTarget) { 142 + e.preventDefault(); 143 + clearSel(); 144 + showMenu(-1, e.clientX, e.clientY); 145 + } 146 + }} 125 147 tabindex="0" 126 148 > 127 149 <div class="head"> ··· 281 303 </div> 282 304 283 305 {#if menu} 306 + {@const isRow = menu.index >= 0} 284 307 <ContextMenu 285 308 x={menu.x} 286 309 y={menu.y} 287 310 onclose={() => (menu = null)} 288 - items={[ 289 - { label: t("play"), action: () => goto(menu!.index) }, 290 - { label: t("play_next"), action: playNext }, 291 - { label: t("sort"), items: getSortItems() }, 292 - { label: t("favorite"), action: () => starSelected(true) }, 293 - { label: t("unfavorite"), action: () => starSelected(false) }, 294 - { 295 - label: t("rating"), 296 - items: [ 297 - { label: "★★★★★", action: () => rateSelected(5) }, 298 - { label: "★★★★", action: () => rateSelected(4) }, 299 - { label: "★★★", action: () => rateSelected(3) }, 300 - { label: "★★", action: () => rateSelected(2) }, 301 - { label: "★", action: () => rateSelected(1) }, 302 - { label: t("none"), action: () => rateSelected(0) }, 303 - ], 304 - }, 305 - { label: t("export"), action: () => (ui.exportPlaylist = true) }, 306 - { label: t("move_up"), action: () => reorder(-1) }, 307 - { label: t("move_down"), action: () => reorder(1) }, 308 - { label: t("clear"), action: remove }, 309 - ]} 311 + items={isRow 312 + ? [ 313 + { label: t("play"), action: () => goto(menu!.index) }, 314 + { label: t("play_next"), action: playNext }, 315 + { type: "separator" }, 316 + { label: t("copy"), action: copy, disabled: !queue.sel.length }, 317 + { label: t("cut"), action: cut, disabled: !queue.sel.length }, 318 + { 319 + label: t("paste"), 320 + action: () => paste(menu!.index + 1), 321 + disabled: !queue.clipboard.length, 322 + }, 323 + { type: "separator" }, 324 + { label: t("favorite"), action: () => starSelected(true) }, 325 + { label: t("unfavorite"), action: () => starSelected(false) }, 326 + { 327 + label: t("rating"), 328 + items: [ 329 + { label: "★★★★★", action: () => rateSelected(5) }, 330 + { label: "★★★★", action: () => rateSelected(4) }, 331 + { label: "★★★", action: () => rateSelected(3) }, 332 + { label: "★★", action: () => rateSelected(2) }, 333 + { label: "★", action: () => rateSelected(1) }, 334 + { label: t("none"), action: () => rateSelected(0) }, 335 + ], 336 + }, 337 + { label: t("sort"), items: getSortItems() }, 338 + { label: t("export"), action: () => (ui.exportPlaylist = true) }, 339 + { type: "separator" }, 340 + { label: t("move_up"), action: () => reorder(-1) }, 341 + { label: t("move_down"), action: () => reorder(1) }, 342 + { label: t("clear"), action: clear }, 343 + ] 344 + : [ 345 + { 346 + label: t("paste"), 347 + action: () => paste(), 348 + disabled: !queue.clipboard.length, 349 + }, 350 + { type: "separator" }, 351 + { label: t("sort"), items: getSortItems() }, 352 + { label: t("clear"), action: clear }, 353 + ]} 310 354 /> 311 355 {/if} 312 356
+4 -1
src/lib/lang/locales/en.ts
··· 7 7 busy: "...", 8 8 clear: "clear", 9 9 close: "close", 10 + copy: "copy", 11 + cut: "cut", 10 12 delete: "delete", 11 13 disc: "disc", 12 14 export: "export", ··· 16 18 loop: "loop", 17 19 next: "next", 18 20 none: "none", 21 + paste: "paste", 19 22 pause: "pause", 20 23 play: "play", 21 24 playlist: "playlist", 22 25 playlists: "playlists", 23 - shared_playlists: "shared playlists", 24 26 prev: "prev", 25 27 public: "public", 26 28 queue: "queue", ··· 30 32 search: "search...", 31 33 selected: "selected", 32 34 settings: "settings", 35 + shared_playlists: "shared playlists", 33 36 shortcuts: "shortcuts", 34 37 song: "song", 35 38 songs: "songs",
+4 -1
src/lib/lang/locales/es.ts
··· 11 11 busy: "...", 12 12 clear: "limpiar", 13 13 close: "cerrar", 14 + copy: "copiar", 15 + cut: "cortar", 14 16 delete: "borrar", 15 17 disc: "disco", 16 18 export: "exportar", ··· 20 22 loop: "bucle", 21 23 next: "siguiente", 22 24 none: "ninguno", 25 + paste: "pegar", 23 26 pause: "pausar", 24 27 play: "reproducir", 25 28 playlist: "lista", 26 29 playlists: "listas", 27 - shared_playlists: "listas compartidas", 28 30 prev: "anterior", 29 31 public: "público", 30 32 queue: "cola", ··· 34 36 search: "buscar...", 35 37 selected: "seleccionado", 36 38 settings: "ajustes", 39 + shared_playlists: "listas compartidas", 37 40 shortcuts: "atajos", 38 41 song: "canción", 39 42 songs: "canciones",
+43 -27
src/lib/queue.svelte.ts
··· 9 9 pos: -1, 10 10 sel: [] as number[], 11 11 version: 0, 12 + clipboard: [] as Song[], 12 13 }); 13 14 14 15 const history: { tracks: Song[]; pos: number }[] = []; 15 16 let hIndex = -1; 16 17 let _undoing = false; 18 + 19 + export const copy = () => { 20 + if (queue.sel.length) queue.clipboard = queue.sel.map((i) => queue.tracks[i]); 21 + }; 22 + 23 + export const cut = () => { 24 + if (!queue.sel.length) return; 25 + copy(); 26 + remove(); 27 + }; 28 + 29 + export const insertAt = ( 30 + index: number, 31 + songs: Song | Song[], 32 + selectInserted = false, 33 + ) => { 34 + const items = asArray(songs).map(internSong); 35 + queue.tracks.splice(index, 0, ...items); 36 + if (queue.pos >= index) queue.pos += items.length; 37 + if (selectInserted) { 38 + queue.sel = Array.from({ length: items.length }, (_, i) => index + i); 39 + nav.anchor = queue.sel[0]; 40 + nav.head = queue.sel[queue.sel.length - 1]; 41 + } 42 + queue.version++; 43 + }; 44 + 45 + export const paste = (index?: number) => { 46 + if (!queue.clipboard.length) return; 47 + const target = 48 + index ?? 49 + (queue.sel.length ? Math.max(...queue.sel) + 1 : queue.tracks.length); 50 + insertAt(target, queue.clipboard, true); 51 + }; 17 52 18 53 export const record = () => { 19 54 const { tracks, pos } = queue; ··· 39 74 queue.tracks = [...state.tracks]; 40 75 const idx = player.track ? queue.tracks.indexOf(player.track) : -1; 41 76 queue.pos = idx !== -1 ? idx : state.pos; 77 + clearSel(); 42 78 queue.version++; 43 79 } 44 80 }; ··· 50 86 queue.tracks = [...state.tracks]; 51 87 const idx = player.track ? queue.tracks.indexOf(player.track) : -1; 52 88 queue.pos = idx !== -1 ? idx : state.pos; 89 + clearSel(); 53 90 queue.version++; 54 91 } 55 92 }; ··· 107 144 export const clearSel = () => { 108 145 queue.sel = []; 109 146 nav.anchor = nav.head = -1; 110 - }; 111 - 112 - export const insertAt = (index: number, songs: Song | Song[]) => { 113 - const items = asArray(songs).map(internSong); 114 - queue.tracks.splice(index, 0, ...items); 115 - if (queue.pos >= index) queue.pos += items.length; 116 - queue.version++; 117 147 }; 118 148 119 149 export const remove = () => { ··· 178 208 export const moveSelected = (targetIndex: number) => { 179 209 if (!queue.sel.length) return; 180 210 181 - const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 182 - const selIndices = [...queue.sel].sort((a, b) => a - b); 183 - const moved = selIndices.map((i) => items[i]); 184 - const remaining = items.filter((_, i) => !queue.sel.includes(i)); 211 + const playing = queue.tracks[queue.pos]; 212 + const moved = queue.sel.sort((a, b) => a - b).map((i) => queue.tracks[i]); 213 + const remaining = queue.tracks.filter((_, i) => !queue.sel.includes(i)); 185 214 186 215 const removedBefore = queue.sel.filter((i) => i < targetIndex).length; 187 216 const actualTarget = Math.max(0, targetIndex - removedBefore); 188 217 189 218 remaining.splice(actualTarget, 0, ...moved); 190 219 191 - queue.tracks = remaining.map((x) => x.t); 192 - queue.pos = remaining.findIndex((x) => x.p); 193 - 220 + queue.tracks = remaining; 221 + queue.pos = playing ? remaining.indexOf(playing) : -1; 194 222 queue.sel = Array.from({ length: moved.length }, (_, i) => actualTarget + i); 195 223 nav.anchor = queue.sel[0]; 196 224 nav.head = queue.sel[queue.sel.length - 1]; ··· 199 227 200 228 export const playNext = () => { 201 229 if (!queue.sel.length || queue.pos === -1) return; 202 - 203 - const items = queue.tracks.map((t, i) => ({ t, p: i === queue.pos })); 204 - const selIndices = [...queue.sel].sort((a, b) => a - b); 205 - const moved = selIndices.map((i) => items[i]); 206 - const remaining = items.filter((_, i) => !queue.sel.includes(i)); 207 - 208 - const newPos = remaining.findIndex((x) => x.p); 209 - remaining.splice(newPos + 1, 0, ...moved); 210 - 211 - queue.tracks = remaining.map((x) => x.t); 212 - queue.pos = remaining.findIndex((x) => x.p); 213 - queue.sel = moved.map((_, i) => newPos + 1 + i); 214 - queue.version++; 230 + moveSelected(queue.pos + 1); 215 231 }; 216 232 217 233 export const sortQueue = (field: string, asc = true) => {