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

Configure Feed

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

at main 431 lines 11 kB view raw
1<script lang="ts"> 2 import { 3 queue, 4 goto, 5 select, 6 clearSel, 7 moveHead, 8 reorder, 9 remove, 10 settings, 11 toggleStar, 12 setRating, 13 formatTime, 14 playNext, 15 moveUp, 16 moveDown, 17 starSelected, 18 rateSelected, 19 selectAll, 20 moveSelected, 21 nav, 22 getSortItems, 23 t, 24 } from "./app.svelte.js"; 25 import { api } from "./client.svelte.js"; 26 import { ui } from "./app.svelte.js"; 27 import ContextMenu from "./ContextMenu.svelte"; 28 import VirtualList from "./VirtualList.svelte"; 29 30 let menu = $state<{ x: number; y: number; index: number } | null>(null), 31 drag = $state<{ index: number; isAfter: boolean } | null>(null), 32 scrollToIndex = $state<(i: number) => void>(); 33 34 $effect(() => { 35 if (nav.head >= 0) scrollToIndex?.(nav.head); 36 }); 37 38 function onDragStart(e: DragEvent, index: number) { 39 if (!queue.sel.includes(index)) select(index); 40 e.dataTransfer!.effectAllowed = "move"; 41 } 42 43 function onDragOver(e: DragEvent, index: number) { 44 e.preventDefault(); 45 const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); 46 drag = { index, isAfter: e.clientY > rect.top + rect.height / 2 }; 47 } 48 49 function onDrop() { 50 if (drag) moveSelected(drag.index + (drag.isAfter ? 1 : 0)); 51 drag = null; 52 } 53 54 function onKey(e: KeyboardEvent) { 55 if ((e.target as HTMLElement).tagName === "INPUT") return; 56 const key = e.key, 57 shift = e.shiftKey, 58 alt = e.altKey; 59 if (e.ctrlKey || e.metaKey) { 60 if (key === "a") { 61 e.preventDefault(); 62 selectAll(); 63 } 64 return; 65 } 66 67 const moves: Record<string, () => void> = { 68 ArrowUp: () => (alt ? reorder(-1) : moveHead(-1, shift)), 69 ArrowDown: () => (alt ? reorder(1) : moveHead(1, shift)), 70 Home: () => select(0, false, shift), 71 End: () => select(queue.tracks.length - 1, false, shift), 72 PageUp: () => moveHead(-10, shift), 73 PageDown: () => moveHead(10, shift), 74 Enter: () => queue.sel.length && goto(queue.sel[0]), 75 Delete: remove, 76 Backspace: remove, 77 Escape: clearSel, 78 ContextMenu: () => showMenu(nav.head), 79 "`": () => showMenu(nav.head), 80 F10: () => e.shiftKey && showMenu(nav.head), 81 }; 82 83 if (moves[key]) { 84 e.preventDefault(); 85 moves[key](); 86 } 87 } 88 89 function showMenu(index: number) { 90 if (index < 0) return; 91 if (!queue.sel.includes(index)) select(index); 92 // Find the row element to position the menu 93 const row = document.querySelector(`[data-id="queue-row-${index}"]`); 94 if (row) { 95 const rect = row.getBoundingClientRect(); 96 menu = { x: rect.left + 50, y: rect.bottom, index }; 97 } else { 98 // Fallback to a central-ish position if row not found (e.g. virtualized out) 99 menu = { x: window.innerWidth / 2, y: window.innerHeight / 3, index }; 100 } 101 } 102</script> 103 104<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> 105<!-- svelte-ignore a11y_no_static_element_interactions --> 106<!-- svelte-ignore a11y_no_noninteractive_tabindex --> 107<div 108 id="queue" 109 style="grid-area: queue" 110 onclick={clearSel} 111 onkeydown={onKey} 112 onfocusin={() => (ui.activeContext = "queue")} 113 tabindex="0" 114> 115 <div class="head"> 116 {#if settings.artSong > 0}<div 117 style="inline-size: {settings.artSong}px;" 118 ></div>{/if} 119 <div class="col">{t("title")}</div> 120 {#if settings.enableArtist}<div class="col">{t("artist")}</div>{/if} 121 {#if settings.enableAlbum}<div class="col">{t("album")}</div>{/if} 122 {#if settings.enableQuality}<div class="col-num">{t("quality")}</div>{/if} 123 {#if settings.enableDuration}<div class="col-num">{t("time")}</div>{/if} 124 {#if settings.enableDisc}<div class="col-num">{t("disc")}</div>{/if} 125 {#if settings.enableTrackNum}<div class="col-num">{t("track")}</div>{/if} 126 {#if settings.enableQueueNum}<div class="col-num">{t("queue")}</div>{/if} 127 {#if settings.enableFavorites}<div class="col-fav"></div>{/if} 128 {#if settings.enableRatings}<div class="col-rate">{t("rating")}</div>{/if} 129 <div class="col-touch"></div> 130 </div> 131 132 <div class="body"> 133 <VirtualList 134 items={queue.tracks} 135 itemHeight={Math.max(18, settings.artSong + 2)} 136 // +2 for 1px padding top and bottom around icon 137 bind:scrollToIndex 138 > 139 {#snippet children(track, index)} 140 <!-- svelte-ignore a11y_click_events_have_key_events --> 141 <div 142 class="row" 143 data-id="queue-row-{index}" 144 class:playing={queue.pos === index} 145 class:selected={queue.sel.includes(index)} 146 class:odd={index % 2 !== 0} 147 class:over-a={drag?.index === index && !drag.isAfter} 148 class:over-b={drag?.index === index && drag.isAfter} 149 style="block-size: {Math.max(18, settings.artSong + 2)}px;" 150 // +2 for 1px padding top and bottom around icon 151 draggable={true} 152 ondragstart={(e) => onDragStart(e, index)} 153 ondragover={(e) => onDragOver(e, index)} 154 ondrop={onDrop} 155 ondragend={() => (drag = null)} 156 onclick={(e) => { 157 e.stopPropagation(); 158 select(index, e.ctrlKey || e.metaKey, e.shiftKey); 159 }} 160 ondblclick={() => { 161 goto(index); 162 clearSel(); 163 }} 164 oncontextmenu={(e) => { 165 e.preventDefault(); 166 if (!queue.sel.includes(index)) select(index); 167 menu = { x: e.clientX, y: e.clientY, index }; 168 }} 169 > 170 {#if settings.artSong > 0} 171 {@const artId = track.albumId || track.coverArt || track.id} 172 <div class="col-art" style="inline-size: {settings.artSong}px;"> 173 <img 174 class="art" 175 src={api.art(artId, settings.artSong)} 176 srcset="{api.art(artId, settings.artSong)} 1x, {api.art( 177 artId, 178 settings.artSong * 2, 179 )} 2x" 180 style="inline-size: {settings.artSong}px; block-size: {settings.artSong}px;" 181 alt="" 182 loading="lazy" 183 /> 184 </div> 185 {/if} 186 <div class="col">{track.title}</div> 187 {#if settings.enableArtist}<div class="col">{track.artist}</div>{/if} 188 {#if settings.enableAlbum}<div class="col">{track.album}</div>{/if} 189 {#if settings.enableQuality} 190 <div class="col-num"> 191 {track.suffix || ""}{#if track.suffix && track.bitRate} 192 {" "}{/if}{track.bitRate || ""} 193 </div> 194 {/if} 195 {#if settings.enableDuration} 196 <div class="col-num">{formatTime(track.duration)}</div> 197 {/if} 198 {#if settings.enableDisc}<div class="col-num"> 199 {track.discNumber || ""} 200 </div>{/if} 201 {#if settings.enableTrackNum}<div class="col-num"> 202 {track.track || ""} 203 </div>{/if} 204 {#if settings.enableQueueNum}<div class="col-num"> 205 {index + 1} 206 </div>{/if} 207 {#if settings.enableFavorites} 208 <div class="col-fav"> 209 <button 210 tabindex="-1" 211 class="btn-fav" 212 class:active={track.starred} 213 onclick={(e) => { 214 e.stopPropagation(); 215 toggleStar(track); 216 }} 217 > 218219 </button> 220 </div> 221 {/if} 222 {#if settings.enableRatings} 223 <div class="col-rate"> 224 {#each [1, 2, 3, 4, 5] as rating} 225 <button 226 tabindex="-1" 227 class="btn-rate" 228 class:active={track.userRating >= rating} 229 onclick={(e) => { 230 e.stopPropagation(); 231 setRating(track, track.userRating === rating ? 0 : rating); 232 }} 233 > 234235 </button> 236 {/each} 237 </div> 238 {/if} 239 <div class="col-touch"> 240 <button 241 tabindex="-1" 242 class="btn-touch" 243 onclick={(e) => { 244 e.stopPropagation(); 245 if (!queue.sel.includes(index)) select(index); 246 reorder(-1); 247 }} 248 title={t("move_up")} 249 > 250251 </button> 252 <button 253 tabindex="-1" 254 class="btn-touch" 255 onclick={(e) => { 256 e.stopPropagation(); 257 if (!queue.sel.includes(index)) select(index); 258 reorder(1); 259 }} 260 title={t("move_down")} 261 > 262263 </button> 264 <button 265 tabindex="-1" 266 class="btn-touch" 267 onclick={(e) => { 268 e.stopPropagation(); 269 goto(index); 270 clearSel(); 271 }} 272 title={t("play")} 273 > 274275 </button> 276 </div> 277 </div> 278 {/snippet} 279 </VirtualList> 280 </div> 281</div> 282 283{#if menu} 284 <ContextMenu 285 x={menu.x} 286 y={menu.y} 287 onclose={() => (menu = null)} 288 items={[ 289 { label: t("play"), action: () => goto(menu!.index) }, 290 { label: t("play_next"), action: playNext }, 291 { 292 label: t("sort"), 293 items: getSortItems(), 294 }, 295 { label: t("favorite"), action: () => starSelected(true) }, 296 { label: t("unfavorite"), action: () => starSelected(false) }, 297 { 298 label: t("rating"), 299 items: [ 300 { label: "★★★★★", action: () => rateSelected(5) }, 301 { label: "★★★★", action: () => rateSelected(4) }, 302 { label: "★★★", action: () => rateSelected(3) }, 303 { label: "★★", action: () => rateSelected(2) }, 304 { label: "★", action: () => rateSelected(1) }, 305 { label: t("none"), action: () => rateSelected(0) }, 306 ], 307 }, 308 { label: t("move_up"), action: moveUp }, 309 { label: t("move_down"), action: moveDown }, 310 { 311 label: t("clear"), 312 action: remove, 313 }, 314 ]} 315 /> 316{/if} 317 318<style> 319 #queue { 320 display: flex; 321 flex-direction: column; 322 overflow: hidden; 323 padding-block-start: 0.5rem; 324 } 325 .body { 326 flex: 1; 327 overflow: hidden; 328 } 329 .head { 330 font-weight: bold; 331 color: inherit; 332 padding-inline: 1rem; 333 } 334 .head, 335 .row { 336 display: flex; 337 align-items: center; 338 } 339 .col { 340 flex: 1; 341 padding-inline: 0.5rem; 342 white-space: nowrap; 343 overflow: hidden; 344 text-overflow: ellipsis; 345 } 346 .col-num { 347 inline-size: 6rem; 348 text-align: end; 349 } 350 .col-fav { 351 inline-size: 2rem; 352 text-align: end; 353 } 354 .col-rate { 355 inline-size: 6rem; 356 text-align: end; 357 } 358 .col-art { 359 display: flex; 360 justify-content: center; 361 } 362 .row.odd { 363 background: var(--bg-row); 364 } 365 .row.playing { 366 background: var(--playing); 367 } 368 .row.selected { 369 background: Highlight; 370 color: HighlightText; 371 } 372 #queue:not(:focus-within) .row.selected { 373 background: GrayText; 374 color: HighlightText; 375 } 376 .row.over-a { 377 box-shadow: inset 0 2px 0 var(--text); 378 } 379 .row.over-b { 380 box-shadow: inset 0 -2px 0 var(--text); 381 } 382 .btn-fav, 383 .btn-rate { 384 background: none; 385 border: none; 386 padding: 0; 387 opacity: 0.25; 388 } 389 .btn-rate { 390 transform: scale(0.75); 391 } 392 .btn-fav.active { 393 color: #ff0008; 394 opacity: 1; 395 } 396 .btn-rate.active { 397 color: #ffd700; 398 opacity: 1; 399 transform: scale(1); 400 } 401 .btn-fav:hover, 402 .btn-rate:hover { 403 opacity: 0.75; 404 } 405 .col-touch { 406 display: none; 407 inline-size: 6rem; 408 justify-content: flex-end; 409 } 410 @media (pointer: coarse) { 411 .col-touch { 412 display: flex; 413 } 414 } 415 .btn-touch { 416 background: none; 417 border: none; 418 padding: 0; 419 opacity: 0.25; 420 font-size: 0.75rem; 421 display: flex; 422 align-items: center; 423 justify-content: center; 424 inline-size: 1.5rem; 425 block-size: 1rem; 426 } 427 :global(body.rounded-art) #queue .art, 428 :global(body.rounded-art) #queue .row { 429 border-radius: 2px; 430 } 431</style>