A music player that connects to your cloud/distributed storage.
5
fork

Configure Feed

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

feat: improve winamp theme

+911 -229
+1
src/components/engine/queue/element.js
··· 27 27 this.add = this.proxy.add; 28 28 this.clear = this.proxy.clear; 29 29 this.fill = this.proxy.fill; 30 + this.move = this.proxy.move; 30 31 this.shift = this.proxy.shift; 31 32 this.supply = this.proxy.supply; 32 33 this.unshift = this.proxy.unshift;
+1
src/components/engine/queue/types.d.ts
··· 14 14 shuffled: boolean; 15 15 }, 16 16 ) => void; 17 + move: (args: { from: number; to: number }) => void; 17 18 shift: () => void; 18 19 supply: (args: { trackIds: string[] }) => void; 19 20 unshift: () => void;
+104
src/components/engine/queue/worker.js
··· 114 114 } 115 115 116 116 /** 117 + * @type {Actions['move']} 118 + * 119 + * @example Moves an item forward in the flat list 120 + * ```js 121 + * import { move, $future } from "~/components/engine/queue/worker.js"; 122 + * 123 + * $future.value = [ 124 + * { id: "a", manualEntry: true }, 125 + * { id: "b", manualEntry: true }, 126 + * { id: "c", manualEntry: true }, 127 + * ]; 128 + * 129 + * move({ from: 0, to: 2 }); 130 + * 131 + * if ($future.value[0].id !== "b") throw new Error("expected 'b' first"); 132 + * if ($future.value[1].id !== "c") throw new Error("expected 'c' second"); 133 + * if ($future.value[2].id !== "a") throw new Error("expected 'a' last"); 134 + * ``` 135 + * 136 + * @example Moves an item backward in the flat list 137 + * ```js 138 + * import { move, $future } from "~/components/engine/queue/worker.js"; 139 + * 140 + * $future.value = [ 141 + * { id: "a", manualEntry: true }, 142 + * { id: "b", manualEntry: true }, 143 + * { id: "c", manualEntry: true }, 144 + * ]; 145 + * 146 + * move({ from: 2, to: 0 }); 147 + * 148 + * if ($future.value[0].id !== "c") throw new Error("expected 'c' first"); 149 + * if ($future.value[1].id !== "a") throw new Error("expected 'a' second"); 150 + * if ($future.value[2].id !== "b") throw new Error("expected 'b' last"); 151 + * ``` 152 + * 153 + * @example Preserves now identity when reordering across past/future 154 + * ```js 155 + * import { move, $past, $now, $future } from "~/components/engine/queue/worker.js"; 156 + * 157 + * $past.value = [{ id: "a", manualEntry: false }]; 158 + * $now.value = { id: "b", manualEntry: false }; 159 + * $future.value = [{ id: "c", manualEntry: false }]; 160 + * 161 + * // flat list is [a(0), b(1), c(2)]; moving c to front → [c, a, b] 162 + * move({ from: 2, to: 0 }); 163 + * 164 + * if ($now.value?.id !== "b") throw new Error("now should still be 'b'"); 165 + * if ($past.value[0]?.id !== "c") throw new Error("expected 'c' first in past"); 166 + * if ($past.value[1]?.id !== "a") throw new Error("expected 'a' second in past"); 167 + * if ($future.value.length !== 0) throw new Error("future should be empty"); 168 + * ``` 169 + * 170 + * @example Does nothing when from equals to 171 + * ```js 172 + * import { move, $future } from "~/components/engine/queue/worker.js"; 173 + * 174 + * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; 175 + * 176 + * move({ from: 1, to: 1 }); 177 + * 178 + * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); 179 + * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); 180 + * ``` 181 + * 182 + * @example Does nothing for out-of-bounds indices 183 + * ```js 184 + * import { move, $future } from "~/components/engine/queue/worker.js"; 185 + * 186 + * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; 187 + * 188 + * move({ from: 0, to: 99 }); 189 + * 190 + * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); 191 + * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); 192 + * ``` 193 + */ 194 + export function move({ from, to }) { 195 + const all = [ 196 + ...$past.value, 197 + ...($now.value ? [$now.value] : []), 198 + ...$future.value, 199 + ]; 200 + 201 + if (from === to || from < 0 || to < 0 || from >= all.length || to >= all.length) return; 202 + 203 + const [item] = all.splice(from, 1); 204 + all.splice(to, 0, item); 205 + 206 + const now = $now.value; 207 + if (now) { 208 + const nowIdx = all.indexOf(now); 209 + $past.value = all.slice(0, nowIdx); 210 + $now.value = all[nowIdx] ?? null; 211 + $future.value = all.slice(nowIdx + 1); 212 + } else { 213 + const pastLen = $past.value.length; 214 + $past.value = all.slice(0, pastLen); 215 + $future.value = all.slice(pastLen); 216 + } 217 + } 218 + 219 + /** 117 220 * @type {Actions['shift']} 118 221 */ 119 222 export function shift() { ··· 215 318 add, 216 319 clear, 217 320 fill, 321 + move, 218 322 shift, 219 323 supply, 220 324 unshift,
+13
src/testing/sample/tracks.js
··· 28 28 }, 29 29 }; 30 30 31 + /** 32 + * @type {Track} 33 + */ 34 + export const trackC = { 35 + $type: "sh.diffuse.output.track", 36 + id: "sample-c", 37 + uri: "http://example.com/audio-c.mp3", 38 + tags: { 39 + title: "Sample C", 40 + }, 41 + }; 42 + 31 43 export const tracks = [ 32 44 trackA, 33 45 trackB, 46 + trackC, 34 47 ];
+707 -229
src/themes/winamp/winamp/element.js
··· 4 4 whenElementsDefined, 5 5 } from "~/common/element.js"; 6 6 import { signal, untracked } from "~/common/signal.js"; 7 + import { repeat } from "lit-html/directives/repeat.js"; 8 + import { guard } from "lit-html/directives/guard.js"; 7 9 8 10 /** 9 11 * @import {RenderArg} from "~/common/element.d.ts" 10 12 * 11 13 * @import ControllerOrchestrator from "~/components/orchestrator/controller/element.js" 12 14 * @import RepeatShuffleEngine from "~/components/engine/repeat-shuffle/element.js" 15 + * @import {Item} from "~/components/engine/queue/types.d.ts" 13 16 */ 14 17 15 18 //////////////////////////////////////////// ··· 35 38 ...JSON.parse(localStorage.getItem(UI_STATE_KEY) ?? "{}"), 36 39 }; 37 40 } catch { 38 - return { eqOpen: true, playlistOpen: true, milkdropOpen: true, eqOn: false, eqSliders: null, mainShade: false, eqShade: false, playlistShade: false, positions: null, sizes: null }; 41 + return { 42 + eqOpen: true, 43 + playlistOpen: true, 44 + milkdropOpen: true, 45 + eqOn: false, 46 + eqSliders: null, 47 + mainShade: false, 48 + eqShade: false, 49 + playlistShade: false, 50 + positions: null, 51 + sizes: null, 52 + }; 39 53 } 40 54 } 41 55 ··· 81 95 #initBitRev(NFREQ) { 82 96 const t = Array.from({ length: NFREQ }, (_, i) => i); 83 97 for (let i = 0, j = 0; i < NFREQ; i++) { 84 - if (j > i) { const tmp = t[i]; t[i] = t[j]; t[j] = tmp; } 98 + if (j > i) { 99 + const tmp = t[i]; 100 + t[i] = t[j]; 101 + t[j] = tmp; 102 + } 85 103 let m = NFREQ >> 1; 86 - while (m >= 1 && j >= m) { j -= m; m >>= 1; } 104 + while (m >= 1 && j >= m) { 105 + j -= m; 106 + m >>= 1; 107 + } 87 108 j += m; 88 109 } 89 110 return t; ··· 104 125 /** @param {number} n @param {number} power */ 105 126 #initEnvelope(n, power) { 106 127 const mult = (1.0 / n) * WinampFFT.#TWO_PI; 107 - return Float32Array.from({ length: n }, (_, i) => 108 - Math.pow(0.5 + 0.5 * Math.sin(i * mult - WinampFFT.#HALF_PI), power) 128 + return Float32Array.from( 129 + { length: n }, 130 + (_, i) => 131 + Math.pow(0.5 + 0.5 * Math.sin(i * mult - WinampFFT.#HALF_PI), power), 109 132 ); 110 133 } 111 134 ··· 147 170 const j = i + half; 148 171 const tr = wr * real[j] - wi * imag[j]; 149 172 const ti = wr * imag[j] + wi * real[j]; 150 - real[j] = real[i] - tr; imag[j] = imag[i] - ti; 151 - real[i] += tr; imag[i] += ti; 173 + real[j] = real[i] - tr; 174 + imag[j] = imag[i] - ti; 175 + real[i] += tr; 176 + imag[i] += ti; 152 177 } 153 178 const wt = wr; 154 179 wr = wr * wpr - wi * wpi; 155 180 wi = wi * wpr + wt * wpi; 156 181 } 157 - dftsize <<= 1; t++; 182 + dftsize <<= 1; 183 + t++; 158 184 } 159 185 160 186 const eq = this.#equalize; ··· 175 201 #marqueeOverride = signal(/** @type {string | null} */ (null)); 176 202 /** @type {ReturnType<typeof setTimeout> | undefined} */ 177 203 #marqueeOverrideTimeout = undefined; 204 + /** @type {ReturnType<typeof setTimeout> | undefined} */ 205 + #isLoadingTimeout = undefined; 206 + #playlist = signal( 207 + /** @type {{ past: Item[]; now: Item | null; future: Item[] }} */ ({ 208 + past: [], 209 + now: null, 210 + future: [], 211 + }), 212 + ); 213 + /** @type {ReturnType<typeof setTimeout> | undefined} */ 214 + #playlistDebounce = undefined; 215 + #dragState = signal( 216 + /** @type {{ fromIdx: number; toIdx: number; startY: number } | null} */ (null), 217 + ); 218 + /** @type {((e: MouseEvent) => void) | null} */ 219 + #dragMouseMove = null; 220 + /** @type {((e: MouseEvent) => void) | null} */ 221 + #dragMouseUp = null; 178 222 #marqueeCurrentOffset = 0; 179 223 /** @type {HTMLElement | null} */ 180 224 #marqueeScroller = null; ··· 204 248 #eqShade = signal(false); 205 249 #playlistShade = signal(false); 206 250 #eqOn = signal(false); 207 - #eqSliders = signal({ preamp: 50, bands: /** @type {number[]} */ (Array(10).fill(50)) }); 251 + #eqSliders = signal({ 252 + preamp: 50, 253 + bands: /** @type {number[]} */ (Array(10).fill(50)), 254 + }); 208 255 #balance = signal(0); 209 256 #stopped = signal(false); 210 257 #seekingProgress = signal(/** @type {number | null} */ (null)); 211 - #focusedWindow = signal(/** @type {"main" | "eq" | "playlist" | "milkdrop"} */ ("main")); 258 + #focusedWindow = signal( 259 + /** @type {"main" | "eq" | "playlist" | "milkdrop"} */ ("main"), 260 + ); 212 261 #playlistOpen = signal(true); 213 262 #milkdropOpen = signal(true); 214 263 ··· 265 314 this.$repeatShuffle.value = repeatShuffle; 266 315 267 316 this.effect(() => { 317 + const queueEl = this.$controller.value?.$queue.value; 318 + const past = queueEl?.past() ?? []; 319 + const now = queueEl?.now() ?? null; 320 + const future = queueEl?.future() ?? []; 321 + clearTimeout(this.#playlistDebounce); 322 + this.#playlistDebounce = setTimeout(() => { 323 + this.#playlist.value = { past, now, future }; 324 + }, 100); 325 + }); 326 + 327 + this.effect(() => { 268 328 const track = this.currentTrack(); 269 329 if (!track) return; // preserve last text during track transitions 270 330 const aud = this.audio(); ··· 294 354 } 295 355 }); 296 356 }); 357 + 358 + this.effect(() => { 359 + const now = !!this.$controller.value?.$queue.value?.now(); 360 + const loadingState = this.audio()?.loadingState(); 361 + const isLoading = now && loadingState !== "loaded"; 362 + 363 + clearTimeout(this.#isLoadingTimeout); 364 + 365 + if (isLoading) { 366 + this.#isLoadingTimeout = setTimeout(() => { 367 + this.#marqueeOverride.value = "Loading audio ..."; 368 + }, 2000); 369 + } else if (this.#marqueeOverride.value === "Loading audio ...") { 370 + this.#marqueeOverride.value = null; 371 + } 372 + }); 297 373 }); 298 374 299 375 // UI State ··· 314 390 } 315 391 316 392 if (ui.sizes) { 317 - if (ui.sizes.playlist) Object.assign(this.#playlistSize, ui.sizes.playlist); 318 - if (ui.sizes.milkdrop) Object.assign(this.#milkdropSize, ui.sizes.milkdrop); 393 + if (ui.sizes.playlist) { 394 + Object.assign(this.#playlistSize, ui.sizes.playlist); 395 + } 396 + if (ui.sizes.milkdrop) { 397 + Object.assign(this.#milkdropSize, ui.sizes.milkdrop); 398 + } 319 399 } 320 400 321 401 if (ui.positions) { 322 402 if (ui.positions.main) Object.assign(this.#mainPos, ui.positions.main); 323 403 if (ui.positions.eq) Object.assign(this.#eqPos, ui.positions.eq); 324 - if (ui.positions.playlist) Object.assign(this.#playlistPos, ui.positions.playlist); 325 - if (ui.positions.milkdrop) Object.assign(this.#milkdropPos, ui.positions.milkdrop); 404 + if (ui.positions.playlist) { 405 + Object.assign(this.#playlistPos, ui.positions.playlist); 406 + } 407 + if (ui.positions.milkdrop) { 408 + Object.assign(this.#milkdropPos, ui.positions.milkdrop); 409 + } 326 410 } else { 327 411 // Center the windows on startup 328 412 const leftColH = 116 + 116 + this.#playlistSize.height; 329 413 const milkdropOpen = this.#milkdropOpen.value; 330 414 const totalW = milkdropOpen ? 275 + this.#milkdropSize.width : 275; 331 - const totalH = milkdropOpen ? Math.max(leftColH, this.#milkdropSize.height) : leftColH; 415 + const totalH = milkdropOpen 416 + ? Math.max(leftColH, this.#milkdropSize.height) 417 + : leftColH; 332 418 const cx = Math.round((window.innerWidth - totalW) / 2); 333 419 const cy = Math.round((window.innerHeight - totalH) / 2); 334 420 this.#mainPos.x = cx; ··· 361 447 362 448 this.forceRender(); 363 449 this.#marqueeScroller = this.root().querySelector("#marquee > div"); 364 - this.#playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 450 + this.#playlistHandle = this.root().querySelector( 451 + ".playlist-scrollbar-handle", 452 + ); 365 453 requestAnimationFrame(() => { 366 454 this.#drawEqGraph(); 367 455 this.#updatePlaylistHandle(); 368 456 }); 369 457 370 458 // Custom playlist scrollbar 371 - const playlistContent = this.root().querySelector(".playlist-middle-center"); 372 - const playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 459 + const playlistContent = this.root().querySelector( 460 + ".playlist-middle-center", 461 + ); 462 + const playlistHandle = this.root().querySelector( 463 + ".playlist-scrollbar-handle", 464 + ); 373 465 const playlistScrollbar = this.root().querySelector(".playlist-scrollbar"); 374 466 if (playlistContent instanceof HTMLElement) { 375 - playlistContent.addEventListener("scroll", () => this.#updatePlaylistHandle()); 467 + playlistContent.addEventListener( 468 + "scroll", 469 + () => this.#updatePlaylistHandle(), 470 + ); 376 471 } 377 472 if ( 378 473 playlistHandle instanceof HTMLElement && ··· 386 481 const range = playlistScrollbar.clientHeight - HANDLE_H; 387 482 const startY = e.clientY; 388 483 const startTop = parseFloat(playlistHandle.style.top) || 0; 389 - const maxScroll = playlistContent.scrollHeight - playlistContent.clientHeight; 484 + const maxScroll = playlistContent.scrollHeight - 485 + playlistContent.clientHeight; 390 486 /** @param {MouseEvent} mv */ 391 487 const onMove = (mv) => { 392 - const newTop = Math.max(0, Math.min(range, startTop + mv.clientY - startY)); 488 + const newTop = Math.max( 489 + 0, 490 + Math.min(range, startTop + mv.clientY - startY), 491 + ); 393 492 const rawScroll = (newTop / range) * maxScroll; 394 493 playlistContent.scrollTop = Math.round(rawScroll / TRACK_H) * TRACK_H; 395 494 }; ··· 431 530 this.root().addEventListener("pointerdown", (e) => { 432 531 if (!(e.target instanceof HTMLElement)) return; 433 532 // Window focus 434 - const win = e.target.closest("#main-window, #equalizer-window, #playlist-window, #playlist-window-shade, #milkdrop-window"); 533 + const win = e.target.closest( 534 + "#main-window, #equalizer-window, #playlist-window, #playlist-window-shade, #milkdrop-window", 535 + ); 435 536 if (win instanceof HTMLElement) { 436 537 if (win.id === "main-window") this.#focusedWindow.value = "main"; 437 - else if (win.id === "equalizer-window") this.#focusedWindow.value = "eq"; 438 - else if (win.id === "milkdrop-window") this.#focusedWindow.value = "milkdrop"; 439 - else this.#focusedWindow.value = "playlist"; 538 + else if (win.id === "equalizer-window") { 539 + this.#focusedWindow.value = "eq"; 540 + } else if (win.id === "milkdrop-window") { 541 + this.#focusedWindow.value = "milkdrop"; 542 + } else this.#focusedWindow.value = "playlist"; 440 543 } 441 544 // Press feedback 442 545 if (e.target.tagName !== "DIV" || !e.target.id) return; ··· 450 553 }); 451 554 452 555 // Window dragging 453 - this.root().addEventListener("mousedown", /** @type {EventListener} */ (this.#onWindowDragStart)); 556 + this.root().addEventListener( 557 + "mousedown", 558 + /** @type {EventListener} */ (this.#onWindowDragStart), 559 + ); 454 560 } 455 561 456 562 /** @override */ 457 563 disconnectedCallback() { 458 564 clearInterval(this.#marqueeStepInterval); 459 - this.root().removeEventListener("mousedown", /** @type {EventListener} */ (this.#onWindowDragStart)); 565 + this.root().removeEventListener( 566 + "mousedown", 567 + /** @type {EventListener} */ (this.#onWindowDragStart), 568 + ); 460 569 if (this.#visRAF !== undefined) { 461 570 cancelAnimationFrame(this.#visRAF); 462 571 this.#visRAF = undefined; ··· 509 618 if (near(a.y, b.y + b.height)) y = b.y + b.height; 510 619 else if (near(a.y + a.height, b.y)) y = b.y - a.height; 511 620 else if (near(a.y, b.y)) y = b.y; 512 - else if (near(a.y + a.height, b.y + b.height)) y = b.y + b.height - a.height; 621 + else if (near(a.y + a.height, b.y + b.height)) { 622 + y = b.y + b.height - a.height; 623 + } 513 624 } 514 625 return { x, y }; 515 626 } ··· 535 646 */ 536 647 static #areTouching(a, b) { 537 648 const T = 1; 538 - const hTouch = Math.abs(a.x - (b.x + b.width)) <= T || Math.abs(b.x - (a.x + a.width)) <= T; 539 - const vTouch = Math.abs(a.y - (b.y + b.height)) <= T || Math.abs(b.y - (a.y + a.height)) <= T; 649 + const hTouch = Math.abs(a.x - (b.x + b.width)) <= T || 650 + Math.abs(b.x - (a.x + a.width)) <= T; 651 + const vTouch = Math.abs(a.y - (b.y + b.height)) <= T || 652 + Math.abs(b.y - (a.y + a.height)) <= T; 540 653 const oX = a.x < b.x + b.width + T && b.x < a.x + a.width + T; 541 654 const oY = a.y < b.y + b.height + T && b.y < a.y + a.height + T; 542 655 return (hTouch && oY) || (vTouch && oX); ··· 577 690 if (win.id === "main-window") { 578 691 const trace = (/** @type {typeof allWindows[0]} */ entry) => { 579 692 for (const other of allWindows) { 580 - if (other === entry || other === draggedEntry || attached.has(other)) continue; 693 + if ( 694 + other === entry || other === draggedEntry || attached.has(other) 695 + ) continue; 581 696 if (WinampElement.#areTouching(entry.box(), other.box())) { 582 697 attached.add(other); 583 698 trace(other); ··· 590 705 const startMouseX = e.clientX; 591 706 const startMouseY = e.clientY; 592 707 const startPos = { x: draggedEntry.pos.x, y: draggedEntry.pos.y }; 593 - const attachedStarts = [...attached].map((w) => ({ w, x: w.pos.x, y: w.pos.y })); 708 + const attachedStarts = [...attached].map((w) => ({ 709 + w, 710 + x: w.pos.x, 711 + y: w.pos.y, 712 + })); 594 713 const snapTargets = allWindows 595 714 .filter((w) => w !== draggedEntry && !attached.has(w)) 596 715 .map((w) => w.box()); ··· 725 844 726 845 #updatePlaylistHandle() { 727 846 if (!this.#playlistHandle?.isConnected) { 728 - this.#playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 847 + this.#playlistHandle = this.root().querySelector( 848 + ".playlist-scrollbar-handle", 849 + ); 729 850 } 730 851 const handle = this.#playlistHandle; 731 852 const content = this.root().querySelector(".playlist-middle-center"); 732 853 const scrollbar = this.root().querySelector(".playlist-scrollbar"); 733 - if (!handle || !(content instanceof HTMLElement) || !(scrollbar instanceof HTMLElement)) return; 854 + if ( 855 + !handle || !(content instanceof HTMLElement) || 856 + !(scrollbar instanceof HTMLElement) 857 + ) return; 734 858 735 859 const HANDLE_H = 18; 736 860 const range = Math.max(0, scrollbar.clientHeight - HANDLE_H); ··· 750 874 { 751 875 el: /** @type {HTMLElement} */ (root.querySelector("#main-window")), 752 876 pos: this.#mainPos, 753 - box: () => ({ x: this.#mainPos.x, y: this.#mainPos.y, width: 275, height: this.#mainShade.value ? 14 : 116 }), 877 + box: () => ({ 878 + x: this.#mainPos.x, 879 + y: this.#mainPos.y, 880 + width: 275, 881 + height: this.#mainShade.value ? 14 : 116, 882 + }), 754 883 }, 755 884 { 756 - el: /** @type {HTMLElement} */ (root.querySelector("#equalizer-window")), 885 + el: 886 + /** @type {HTMLElement} */ (root.querySelector("#equalizer-window")), 757 887 pos: this.#eqPos, 758 - box: () => ({ x: this.#eqPos.x, y: this.#eqPos.y, width: 275, height: this.#eqShade.value ? 14 : 116 }), 888 + box: () => ({ 889 + x: this.#eqPos.x, 890 + y: this.#eqPos.y, 891 + width: 275, 892 + height: this.#eqShade.value ? 14 : 116, 893 + }), 759 894 }, 760 895 { 761 896 el: /** @type {HTMLElement} */ (root.querySelector("#playlist-window")), 762 897 pos: this.#playlistPos, 763 - box: () => ({ x: this.#playlistPos.x, y: this.#playlistPos.y, width: ps.width, height: ps.height }), 898 + box: () => ({ 899 + x: this.#playlistPos.x, 900 + y: this.#playlistPos.y, 901 + width: ps.width, 902 + height: ps.height, 903 + }), 764 904 }, 765 905 { 766 906 el: /** @type {HTMLElement} */ (root.querySelector("#milkdrop-window")), 767 907 pos: this.#milkdropPos, 768 - box: () => ({ x: this.#milkdropPos.x, y: this.#milkdropPos.y, width: this.#milkdropSize.width, height: this.#milkdropSize.height }), 908 + box: () => ({ 909 + x: this.#milkdropPos.x, 910 + y: this.#milkdropPos.y, 911 + width: this.#milkdropSize.width, 912 + height: this.#milkdropSize.height, 913 + }), 769 914 }, 770 915 ].filter((e) => e.el != null); 771 916 } ··· 793 938 794 939 // Webamp default viscolors[0..15]: y=0 top (black), y=2 loud (red), y=15 quiet (green) 795 940 static #BAR_COLORS = [ 796 - "rgb(0,0,0)", // 0 — black (never visible) 797 - "rgb(24,33,41)", // 1 — grid dot color (never visible with pushDown=2) 798 - "rgb(239,49,16)", // 2 — bright red (loud) 799 - "rgb(206,41,16)", // 3 800 - "rgb(214,90,0)", // 4 801 - "rgb(214,102,0)", // 5 802 - "rgb(214,115,0)", // 6 803 - "rgb(198,123,8)", // 7 804 - "rgb(222,165,24)", // 8 805 - "rgb(214,181,33)", // 9 806 - "rgb(189,222,41)", // 10 807 - "rgb(148,222,33)", // 11 808 - "rgb(41,206,16)", // 12 809 - "rgb(50,190,16)", // 13 810 - "rgb(57,181,16)", // 14 811 - "rgb(49,156,8)", // 15 — dim green (quiet) 941 + "rgb(0,0,0)", // 0 — black (never visible) 942 + "rgb(24,33,41)", // 1 — grid dot color (never visible with pushDown=2) 943 + "rgb(239,49,16)", // 2 — bright red (loud) 944 + "rgb(206,41,16)", // 3 945 + "rgb(214,90,0)", // 4 946 + "rgb(214,102,0)", // 5 947 + "rgb(214,115,0)", // 6 948 + "rgb(198,123,8)", // 7 949 + "rgb(222,165,24)", // 8 950 + "rgb(214,181,33)", // 9 951 + "rgb(189,222,41)", // 10 952 + "rgb(148,222,33)", // 11 953 + "rgb(41,206,16)", // 12 954 + "rgb(50,190,16)", // 13 955 + "rgb(57,181,16)", // 14 956 + "rgb(49,156,8)", // 15 — dim green (quiet) 812 957 ]; 813 958 814 959 #ensureAnalyser() { ··· 817 962 818 963 this.#preampNode = this.#audioCtx.createGain(); 819 964 this.#eqNodes = EQ_BANDS.map((freq) => { 820 - const f = /** @type {AudioContext} */ (this.#audioCtx).createBiquadFilter(); 965 + const f = /** @type {AudioContext} */ (this.#audioCtx) 966 + .createBiquadFilter(); 821 967 f.type = "peaking"; 822 968 f.frequency.value = freq; 823 969 f.Q.value = 1.0; ··· 886 1032 gradCanvas.width = 1; 887 1033 const gradCtx = gradCanvas.getContext("2d"); 888 1034 const peakCanvas = document.createElement("canvas"); 889 - peakCanvas.width = 1; peakCanvas.height = 1; 1035 + peakCanvas.width = 1; 1036 + peakCanvas.height = 1; 890 1037 const peakCtx = peakCanvas.getContext("2d"); 891 1038 if (peakCtx) { 892 1039 peakCtx.fillStyle = "rgb(150,150,150)"; ··· 898 1045 899 1046 /** @param {number} W @param {number} H */ 900 1047 const rebuildCaches = (W, H) => { 901 - bgCanvas.width = W; bgCanvas.height = H; 1048 + bgCanvas.width = W; 1049 + bgCanvas.height = H; 902 1050 if (bgCtx) { 903 1051 bgCtx.fillStyle = "rgb(0,0,0)"; 904 1052 bgCtx.fillRect(0, 0, W, H); 905 1053 bgCtx.fillStyle = "rgb(24,33,41)"; 906 - for (let x = 0; x < W; x += 2) 907 - for (let y = 1; y < H; y += 2) 1054 + for (let x = 0; x < W; x += 2) { 1055 + for (let y = 1; y < H; y += 2) { 908 1056 bgCtx.fillRect(x, y, 1, 1); 1057 + } 1058 + } 909 1059 } 910 1060 gradCanvas.height = H; 911 1061 if (gradCtx) { 912 1062 const maxColorIdx = WinampElement.#BAR_COLORS.length - 1; 913 1063 for (let y = 0; y < H; y++) { 914 1064 const colorIdx = H > 1 ? Math.round((y / (H - 1)) * maxColorIdx) : 0; 915 - gradCtx.fillStyle = WinampElement.#BAR_COLORS[colorIdx] ?? "rgb(0,0,0)"; 1065 + gradCtx.fillStyle = WinampElement.#BAR_COLORS[colorIdx] ?? 1066 + "rgb(0,0,0)"; 916 1067 gradCtx.fillRect(0, y, 1, 1); 917 1068 } 918 1069 } 919 - cachedH = H; cachedW = W; 1070 + cachedH = H; 1071 + cachedW = W; 920 1072 }; 921 1073 922 1074 const logMaxFreqIndex = Math.log10(512); ··· 941 1093 942 1094 // Time-domain → FFT → spectral data 943 1095 analyser.getByteTimeDomainData(timeDomainBuf); 944 - for (let i = 0; i < 1024; i++) inWaveData[i] = (timeDomainBuf[i] - 128) / 24; 1096 + for (let i = 0; i < 1024; i++) { 1097 + inWaveData[i] = (timeDomainBuf[i] - 128) / 24; 1098 + } 945 1099 fft.timeToFrequencyDomain(inWaveData, outSpectralData); 946 1100 947 1101 for (let x = 0; x < MAX_WIDTH; x++) { ··· 952 1106 const i2 = Math.min(511, Math.ceil(si)); 953 1107 sample[x] = i1 === i2 954 1108 ? outSpectralData[i1] 955 - : (1 - (si - i1)) * outSpectralData[i1] + (si - i1) * outSpectralData[i2]; 1109 + : (1 - (si - i1)) * outSpectralData[i1] + 1110 + (si - i1) * outSpectralData[i2]; 956 1111 } 957 1112 958 1113 ctx.drawImage(bgCanvas, 0, 0); ··· 961 1116 const chunk = x & ~3; 962 1117 const saData = Math.min( 963 1118 (((sample[chunk] ?? 0) + (sample[chunk + 1] ?? 0) + 964 - (sample[chunk + 2] ?? 0) + (sample[chunk + 3] ?? 0)) / 4) * HEIGHT_SCALE, 965 - MAX_HEIGHT 1119 + (sample[chunk + 2] ?? 0) + (sample[chunk + 3] ?? 0)) / 4) * 1120 + HEIGHT_SCALE, 1121 + MAX_HEIGHT, 966 1122 ); 967 1123 968 1124 if (saPeaks[x] >= MAX_HEIGHT * 256) saPeaks[x] = MAX_HEIGHT * 256; ··· 984 1140 985 1141 const barHeight = Math.round(saFalloff[x]) - PUSH_DOWN; 986 1142 if (barHeight > 0) { 987 - ctx.drawImage(gradCanvas, 0, H - barHeight, 1, barHeight, x, H - barHeight, 1, barHeight); 1143 + ctx.drawImage( 1144 + gradCanvas, 1145 + 0, 1146 + H - barHeight, 1147 + 1, 1148 + barHeight, 1149 + x, 1150 + H - barHeight, 1151 + 1, 1152 + barHeight, 1153 + ); 988 1154 } 989 1155 990 1156 const peakHeight = barPeak[x] + 1 - PUSH_DOWN; ··· 1014 1180 const { preamp, bands } = this.#eqSliders.value; 1015 1181 /** @type {Record<string, number>} */ 1016 1182 const sliders = { preamp }; 1017 - bands.forEach((v, i) => { sliders[`band_${i}`] = v; }); 1183 + bands.forEach((v, i) => { 1184 + sliders[`band_${i}`] = v; 1185 + }); 1018 1186 const ui = loadUiState(); 1019 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, eqOn: this.#eqOn.value, eqSliders: sliders })); 1187 + localStorage.setItem( 1188 + UI_STATE_KEY, 1189 + JSON.stringify({ ...ui, eqOn: this.#eqOn.value, eqSliders: sliders }), 1190 + ); 1020 1191 } 1021 1192 1022 1193 /** ··· 1027 1198 #startSliderDrag(e, isPreamp, bandIndex = 0) { 1028 1199 e.preventDefault(); 1029 1200 const RANGE = 52; // px travel for handle (63 - 11) 1030 - const SNAP = 5; // webamp BAND_SNAP_DISTANCE 1201 + const SNAP = 5; // webamp BAND_SNAP_DISTANCE 1031 1202 const startY = e.clientY; 1032 - const startValue = isPreamp ? this.#eqSliders.value.preamp : this.#eqSliders.value.bands[bandIndex]; 1203 + const startValue = isPreamp 1204 + ? this.#eqSliders.value.preamp 1205 + : this.#eqSliders.value.bands[bandIndex]; 1033 1206 const startTop = Math.floor((1 - startValue / 100) * RANGE); 1034 1207 1035 1208 const onMove = (/** @type {MouseEvent} */ mv) => { 1036 - const newTop = Math.max(0, Math.min(RANGE, startTop + mv.clientY - startY)); 1209 + const newTop = Math.max( 1210 + 0, 1211 + Math.min(RANGE, startTop + mv.clientY - startY), 1212 + ); 1037 1213 const raw = Math.round((1 - newTop / RANGE) * 100); 1038 1214 const value = Math.abs(raw - 50) < SNAP ? 50 : raw; 1039 1215 const cur = this.#eqSliders.value; ··· 1116 1292 const i2 = Math.min(9, i1 + 1); 1117 1293 const v = bands[i1] * (1 - (t - i1)) + bands[i2] * (t - i1); 1118 1294 const y = Math.round((1 - v / 100) * H); 1119 - const [r, g, b] = WinampElement.#EQ_COLORS[y] ?? WinampElement.#EQ_COLORS[0]; 1295 + const [r, g, b] = WinampElement.#EQ_COLORS[y] ?? 1296 + WinampElement.#EQ_COLORS[0]; 1120 1297 ctx.fillStyle = `rgb(${r},${g},${b})`; 1121 1298 ctx.fillRect(paddingLeft + x, y, 1, 1); 1122 1299 } ··· 1152 1329 const secs = Math.round(percentage * duration); 1153 1330 const m = Math.floor(secs / 60); 1154 1331 const s = secs % 60; 1155 - this.#marqueeOverride.value = `Seek to: ${m}:${String(s).padStart(2, "0")} (${Math.round(percentage * 100)}%)`; 1332 + this.#marqueeOverride.value = `Seek to: ${m}:${ 1333 + String(s).padStart(2, "0") 1334 + } (${Math.round(percentage * 100)}%)`; 1156 1335 clearTimeout(this.#marqueeOverrideTimeout); 1157 1336 }; 1158 1337 ··· 1161 1340 if (!(e.target instanceof HTMLInputElement)) return; 1162 1341 const percentage = Number(e.target.value) / 100; 1163 1342 const audioId = this.$controller.value?.$queue.value?.now()?.id; 1164 - if (audioId) this.$controller.value?.$audio.value?.seek({ audioId, percentage }); 1343 + if (audioId) { 1344 + this.$controller.value?.$audio.value?.seek({ audioId, percentage }); 1345 + } 1165 1346 this.#marqueeOverride.value = null; 1166 - setTimeout(() => { this.#seekingProgress.value = null; }, 250); 1347 + setTimeout(() => { 1348 + this.#seekingProgress.value = null; 1349 + }, 250); 1167 1350 }; 1168 1351 1169 1352 #playPause = () => { ··· 1179 1362 #stop = () => { 1180 1363 const audioId = this.$controller.value?.$queue.value?.now()?.id; 1181 1364 if (!audioId) return; 1182 - if (this.isPlaying()) this.$controller.value?.$audio.value?.pause({ audioId }); 1365 + if (this.isPlaying()) { 1366 + this.$controller.value?.$audio.value?.pause({ audioId }); 1367 + } 1183 1368 this.$controller.value?.$audio.value?.seek({ audioId, percentage: 0 }); 1184 1369 this.#stopped.value = true; 1185 1370 }; ··· 1210 1395 const leftColH = 116 + 116 + this.#playlistSize.height; 1211 1396 const milkdropOpen = this.#milkdropOpen.value; 1212 1397 const totalW = milkdropOpen ? 275 + this.#milkdropSize.width : 275; 1213 - const totalH = milkdropOpen ? Math.max(leftColH, this.#milkdropSize.height) : leftColH; 1398 + const totalH = milkdropOpen 1399 + ? Math.max(leftColH, this.#milkdropSize.height) 1400 + : leftColH; 1214 1401 const cx = Math.round((window.innerWidth - totalW) / 2); 1215 1402 const cy = Math.round((window.innerHeight - totalH) / 2); 1216 - this.#mainPos.x = cx; this.#mainPos.y = cy; 1217 - this.#eqPos.x = cx; this.#eqPos.y = cy + 116; 1218 - this.#playlistPos.x = cx; this.#playlistPos.y = cy + 232; 1219 - this.#milkdropPos.x = cx + 275; this.#milkdropPos.y = cy; 1403 + this.#mainPos.x = cx; 1404 + this.#mainPos.y = cy; 1405 + this.#eqPos.x = cx; 1406 + this.#eqPos.y = cy + 116; 1407 + this.#playlistPos.x = cx; 1408 + this.#playlistPos.y = cy + 232; 1409 + this.#milkdropPos.x = cx + 275; 1410 + this.#milkdropPos.y = cy; 1220 1411 this.forceRender(); 1221 1412 this.#saveLayout(); 1222 1413 }; 1223 1414 1224 1415 #saveLayout = () => { 1225 1416 const ui = loadUiState(); 1226 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ 1227 - ...ui, 1228 - positions: { 1229 - main: { ...this.#mainPos }, 1230 - eq: { ...this.#eqPos }, 1231 - playlist: { ...this.#playlistPos }, 1232 - milkdrop: { ...this.#milkdropPos }, 1233 - }, 1234 - sizes: { 1235 - playlist: { ...this.#playlistSize }, 1236 - milkdrop: { ...this.#milkdropSize }, 1237 - }, 1238 - })); 1417 + localStorage.setItem( 1418 + UI_STATE_KEY, 1419 + JSON.stringify({ 1420 + ...ui, 1421 + positions: { 1422 + main: { ...this.#mainPos }, 1423 + eq: { ...this.#eqPos }, 1424 + playlist: { ...this.#playlistPos }, 1425 + milkdrop: { ...this.#milkdropPos }, 1426 + }, 1427 + sizes: { 1428 + playlist: { ...this.#playlistSize }, 1429 + milkdrop: { ...this.#milkdropSize }, 1430 + }, 1431 + }), 1432 + ); 1239 1433 }; 1240 1434 1241 1435 #toggleMilkdrop = () => { 1242 1436 this.#milkdropOpen.value = !this.#milkdropOpen.value; 1243 1437 const ui = loadUiState(); 1244 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, milkdropOpen: this.#milkdropOpen.value })); 1438 + localStorage.setItem( 1439 + UI_STATE_KEY, 1440 + JSON.stringify({ ...ui, milkdropOpen: this.#milkdropOpen.value }), 1441 + ); 1442 + }; 1443 + 1444 + #toggleMilkdropFullscreen = () => { 1445 + const canvas = this.root().querySelector("#milkdrop-canvas"); 1446 + if (!(canvas instanceof HTMLCanvasElement)) return; 1447 + 1448 + if (document.fullscreenElement) { 1449 + document.exitFullscreen(); 1450 + } else { 1451 + canvas.requestFullscreen(); 1452 + canvas.addEventListener("fullscreenchange", () => { 1453 + const cw = canvas.clientWidth; 1454 + const ch = canvas.clientHeight; 1455 + canvas.width = cw; 1456 + canvas.height = ch; 1457 + this.#butterchurn?.setRendererSize(cw, ch); 1458 + }, { once: true }); 1459 + } 1245 1460 }; 1246 1461 1247 1462 /** @param {HTMLCanvasElement} canvas */ ··· 1250 1465 if (!this.#audioCtx || !this.#analyser) return; 1251 1466 1252 1467 const { default: butterchurn } = await import("butterchurn"); 1253 - const w = canvas.clientWidth || canvas.offsetWidth || this.#milkdropSize.width; 1254 - const h = canvas.clientHeight || canvas.offsetHeight || (this.#milkdropSize.height - 34); 1468 + const w = canvas.clientWidth || canvas.offsetWidth || 1469 + this.#milkdropSize.width; 1470 + const h = canvas.clientHeight || canvas.offsetHeight || 1471 + (this.#milkdropSize.height - 34); 1255 1472 canvas.width = w; 1256 1473 canvas.height = h; 1257 - this.#butterchurn = butterchurn.createVisualizer(this.#audioCtx, canvas, { width: w, height: h }); 1474 + this.#butterchurn = butterchurn.createVisualizer(this.#audioCtx, canvas, { 1475 + width: w, 1476 + height: h, 1477 + }); 1258 1478 this.#butterchurn.connectAudio(this.#analyser); 1259 1479 1260 1480 const { default: raw } = await import("butterchurn-presets/dist/base.js"); 1261 - const presets = typeof raw?.default === "object" && raw.default !== null ? raw.default : raw; 1481 + const presets = typeof raw?.default === "object" && raw.default !== null 1482 + ? raw.default 1483 + : raw; 1262 1484 this.#butterchurnPresetList = Object.values(presets ?? {}); 1263 1485 this.#cyclePreset(0); 1264 1486 1265 - this.#butterchurnCycleInterval = setInterval(() => this.#cyclePreset(5.7), 15000); 1487 + this.#butterchurnCycleInterval = setInterval( 1488 + () => this.#cyclePreset(5.7), 1489 + 15000, 1490 + ); 1266 1491 this.#startButterchurn(); 1267 1492 }; 1268 1493 ··· 1270 1495 if (this.#butterchurnRAF !== undefined) return; 1271 1496 const step = () => { 1272 1497 this.#butterchurnRAF = requestAnimationFrame(step); 1273 - if (this.isPlaying()) this.#butterchurn?.render(); 1498 + if (this.isPlaying()) { 1499 + try { 1500 + this.#butterchurn?.render(); 1501 + } catch { 1502 + this.#cyclePreset(0); 1503 + } 1504 + } 1274 1505 }; 1275 1506 step(); 1276 - if (this.#butterchurnCycleInterval === undefined && this.#butterchurnPresetList.length) { 1277 - this.#butterchurnCycleInterval = setInterval(() => this.#cyclePreset(5.7), 15000); 1507 + if ( 1508 + this.#butterchurnCycleInterval === undefined && 1509 + this.#butterchurnPresetList.length 1510 + ) { 1511 + this.#butterchurnCycleInterval = setInterval( 1512 + () => this.#cyclePreset(5.7), 1513 + 15000, 1514 + ); 1278 1515 } 1279 1516 }; 1280 1517 ··· 1299 1536 1300 1537 #closeMain = () => { 1301 1538 const audioId = this.$controller.value?.$queue.value?.now()?.id; 1302 - if (audioId && this.isPlaying()) this.$controller.value?.$audio.value?.pause({ audioId }); 1539 + if (audioId && this.isPlaying()) { 1540 + this.$controller.value?.$audio.value?.pause({ audioId }); 1541 + } 1303 1542 this.#mainOpen.value = false; 1304 1543 }; 1305 1544 ··· 1319 1558 #toggleMainShade = () => { 1320 1559 this.#mainShade.value = !this.#mainShade.value; 1321 1560 const ui = loadUiState(); 1322 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, mainShade: this.#mainShade.value })); 1561 + localStorage.setItem( 1562 + UI_STATE_KEY, 1563 + JSON.stringify({ ...ui, mainShade: this.#mainShade.value }), 1564 + ); 1323 1565 }; 1324 1566 1325 1567 #toggleEqShade = () => { 1326 1568 this.#eqShade.value = !this.#eqShade.value; 1327 1569 const ui = loadUiState(); 1328 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, eqShade: this.#eqShade.value })); 1570 + localStorage.setItem( 1571 + UI_STATE_KEY, 1572 + JSON.stringify({ ...ui, eqShade: this.#eqShade.value }), 1573 + ); 1329 1574 if (!this.#eqShade.value) { 1330 1575 requestAnimationFrame(() => this.#drawEqGraph()); 1331 1576 } ··· 1334 1579 #togglePlaylistShade = () => { 1335 1580 this.#playlistShade.value = !this.#playlistShade.value; 1336 1581 const ui = loadUiState(); 1337 - localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, playlistShade: this.#playlistShade.value })); 1582 + localStorage.setItem( 1583 + UI_STATE_KEY, 1584 + JSON.stringify({ ...ui, playlistShade: this.#playlistShade.value }), 1585 + ); 1338 1586 }; 1339 1587 1340 1588 #togglePlaylist = () => { ··· 1351 1599 this.#selectedIndex.value = idx; 1352 1600 }; 1353 1601 1602 + static #TRACK_HEIGHT = 13; 1603 + 1604 + /** 1605 + * @param {MouseEvent} e 1606 + * @param {number} idx 1607 + * @param {number} totalItems 1608 + */ 1609 + #onTrackMouseDown = (e, idx, totalItems) => { 1610 + e.preventDefault(); 1611 + this.#dragState.value = { fromIdx: idx, toIdx: idx, startY: e.clientY }; 1612 + 1613 + this.#dragMouseMove = (mv) => { 1614 + const state = this.#dragState.value; 1615 + if (!state) return; 1616 + const diff = Math.round( 1617 + (mv.clientY - state.startY) / WinampElement.#TRACK_HEIGHT, 1618 + ); 1619 + const toIdx = Math.max(0, Math.min(totalItems - 1, state.fromIdx + diff)); 1620 + if (toIdx !== state.toIdx) this.#dragState.value = { ...state, toIdx }; 1621 + }; 1622 + 1623 + this.#dragMouseUp = () => { 1624 + const state = this.#dragState.value; 1625 + if (state) { 1626 + if (state.fromIdx !== state.toIdx) { 1627 + this.$controller.value?.$queue.value?.move({ 1628 + from: state.fromIdx, 1629 + to: state.toIdx, 1630 + }); 1631 + // Immediately commit the reorder to #playlist so the item stays in 1632 + // place while the debounce is still pending. 1633 + const { past, now, future } = this.#playlist.value; 1634 + const all = [...past, ...(now ? [now] : []), ...future]; 1635 + const [item] = all.splice(state.fromIdx, 1); 1636 + all.splice(state.toIdx, 0, item); 1637 + const nowIdx = now ? all.indexOf(now) : past.length; 1638 + clearTimeout(this.#playlistDebounce); 1639 + this.#playlist.value = { 1640 + past: all.slice(0, nowIdx), 1641 + now: now ? (all[nowIdx] ?? null) : null, 1642 + future: all.slice(nowIdx + (now ? 1 : 0)), 1643 + }; 1644 + } 1645 + this.#selectedIndex.value = state.toIdx; 1646 + } 1647 + this.#dragState.value = null; 1648 + if (this.#dragMouseMove) { 1649 + window.removeEventListener("mousemove", this.#dragMouseMove); 1650 + } 1651 + if (this.#dragMouseUp) { 1652 + window.removeEventListener("mouseup", this.#dragMouseUp); 1653 + } 1654 + this.#dragMouseMove = null; 1655 + this.#dragMouseUp = null; 1656 + }; 1657 + 1658 + window.addEventListener("mousemove", this.#dragMouseMove); 1659 + window.addEventListener("mouseup", this.#dragMouseUp); 1660 + }; 1661 + 1354 1662 /** @param {number} idx */ 1355 1663 #playTrack = (idx) => { 1356 1664 this.#selectedIndex.value = idx; ··· 1389 1697 const sx = (n % 14) * 15; 1390 1698 const sy = Math.floor(n / 14) * 65; 1391 1699 return html` 1392 - <div class="band" style="background-position: -${sx}px -${sy}px; width: 14px; height: 63px; position: relative;"> 1700 + <div 1701 + class="band" 1702 + style="background-position: -${sx}px -${sy}px; width: 14px; height: 63px; position: relative;" 1703 + > 1393 1704 <div 1394 1705 class="slider-handle" 1395 1706 style="position: absolute; top: ${handleTop}px; width: 11px; height: 11px; margin-left: 1px;" 1396 - @mousedown="${(/** @type {MouseEvent} */ e) => this.#startSliderDrag(e, isPreamp, bandIndex)}" 1397 - ></div> 1707 + @mousedown="${(/** @type {MouseEvent} */ e) => 1708 + this.#startSliderDrag(e, isPreamp, bandIndex)}" 1709 + > 1710 + </div> 1398 1711 </div> 1399 1712 `; 1400 1713 }; ··· 1403 1716 const volumeSprite = Math.round(volume * 28); 1404 1717 const volumeBgPos = `0 -${(volumeSprite - 1) * 15}px`; 1405 1718 const volumePct = Math.round(volume * 100); 1406 - const volumeClass = volumePct < 50 ? "left" : volumePct > 50 ? "right" : "center"; 1719 + const volumeClass = volumePct < 50 1720 + ? "left" 1721 + : volumePct > 50 1722 + ? "right" 1723 + : "center"; 1407 1724 const balance = this.#balance.value; 1408 - const balanceClass = balance < 0 ? "left" : balance > 0 ? "right" : "center"; 1725 + const balanceClass = balance < 0 1726 + ? "left" 1727 + : balance > 0 1728 + ? "right" 1729 + : "center"; 1409 1730 1410 1731 const audio = this.audio(); 1411 1732 const focused = this.#focusedWindow.value; 1412 1733 1413 1734 // Track metadata 1414 1735 const track = this.currentTrack(); 1415 - const kbps = track?.stats?.bitrate ? Math.round(track.stats.bitrate / 1000) : null; 1416 - const khz = track?.stats?.sampleRate ? Math.round(track.stats.sampleRate / 1000) : null; 1736 + const kbps = track?.stats?.bitrate 1737 + ? Math.round(track.stats.bitrate / 1000) 1738 + : null; 1739 + const khz = track?.stats?.sampleRate 1740 + ? Math.round(track.stats.sampleRate / 1000) 1741 + : null; 1417 1742 const channels = track?.stats?.numberOfChannels ?? null; 1418 1743 const isStereo = channels !== null && channels >= 2; 1419 1744 const isMono = channels === 1; 1420 - const kbpsChars = kbps != null ? [...String(kbps)].map((c) => 1421 - html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1422 - ) : []; 1423 - const khzChars = khz != null ? [...String(khz)].map((c) => 1424 - html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1425 - ) : []; 1745 + const kbpsChars = kbps != null 1746 + ? [...String(kbps)].map((c) => 1747 + html` 1748 + <span class="character character-${c.charCodeAt(0)}">${c}</span> 1749 + ` 1750 + ) 1751 + : []; 1752 + const khzChars = khz != null 1753 + ? [...String(khz)].map((c) => 1754 + html` 1755 + <span class="character character-${c.charCodeAt(0)}">${c}</span> 1756 + ` 1757 + ) 1758 + : []; 1426 1759 1427 1760 const seekPct = this.#seekingProgress.value; 1428 1761 const timeSeconds = seekPct !== null ··· 1430 1763 : audio?.currentTime() ?? 0; 1431 1764 const timeMinutes = Math.floor(timeSeconds / 60); 1432 1765 const timeSecs = Math.floor(timeSeconds % 60); 1433 - const miniTimeStr = `${String(timeMinutes).padStart(2, "0")}:${String(timeSecs).padStart(2, "0")}`; 1766 + const miniTimeStr = `${String(timeMinutes).padStart(2, "0")}:${ 1767 + String(timeSecs).padStart(2, "0") 1768 + }`; 1434 1769 const miniTimeChars = [...miniTimeStr].map((c, i) => 1435 - c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1770 + c === ":" ? null : html` 1771 + <span class="character character-${c.charCodeAt(0)}" style="left: ${i * 1772 + 5}px" 1773 + >${c}</span> 1774 + ` 1436 1775 ); 1437 1776 const d = { 1438 1777 mFirst: Math.floor(timeMinutes / 10), ··· 1443 1782 1444 1783 // Playlist 1445 1784 const queueEl = this.$controller.value?.$queue.value; 1446 - const nowItem = queueEl?.now(); 1785 + const { past: queuePast, now: nowItem, future: queueFuture } = 1786 + this.#playlist.value; 1447 1787 const allItems = [ 1448 - ...(queueEl?.past() ?? []), 1788 + ...queuePast, 1449 1789 ...(nowItem ? [nowItem] : []), 1450 - ...(queueEl?.future() ?? []), 1790 + ...queueFuture, 1451 1791 ]; 1792 + 1793 + // Apply local drag reorder for visual feedback (worker is only called on mouseup) 1794 + const dragState = this.#dragState.value; 1795 + const displayItems = dragState && dragState.fromIdx !== dragState.toIdx 1796 + ? (() => { 1797 + const arr = [...allItems]; 1798 + const [item] = arr.splice(dragState.fromIdx, 1); 1799 + arr.splice(dragState.toIdx, 0, item); 1800 + return arr; 1801 + })() 1802 + : allItems; 1803 + 1452 1804 const col = this.$controller.value?.$output.value?.tracks.collection(); 1453 1805 const trackMap = col?.state === "loaded" 1454 1806 ? new Map(col.data.map((t) => [t.id, t])) 1455 1807 : new Map(); 1456 1808 const selectedIdx = this.#selectedIndex.value; 1457 - const nowIdx = nowItem ? (queueEl?.past().length ?? 0) : -1; 1458 - const playlistRows = allItems.map((item, i) => { 1809 + const nowIdx = nowItem ? displayItems.indexOf(nowItem) : -1; 1810 + const playlistRows = displayItems.map((item, i) => { 1459 1811 const track = trackMap.get(item.id); 1460 1812 const isCurrent = i === nowIdx; 1461 - const isSelected = selectedIdx === i; 1813 + const isSelected = dragState ? i === dragState.toIdx : selectedIdx === i; 1462 1814 const artist = track?.tags?.artist ?? ""; 1463 1815 const title = track?.tags?.title ?? ""; 1464 1816 const label = artist ? `${artist} - ${title}` : title; ··· 1470 1822 : ""; 1471 1823 const color = isCurrent ? "#FFFFFF" : "#00FF00"; 1472 1824 const bg = isSelected && !isCurrent ? "#0000FF" : "transparent"; 1473 - return { idx: i, n: i + 1, label, dur, color, bg }; 1825 + return { id: item.id, idx: i, n: i + 1, label, dur, color, bg }; 1474 1826 }); 1475 1827 1476 1828 // Playlist running time display: currentTrackDuration/totalPlaylistDuration ··· 1493 1845 }; 1494 1846 const runningTimeStr = `${fmtDur(nowTrackSec)}/${fmtDur(totalSec)}`; 1495 1847 const totalTimeChars = [...runningTimeStr].map((c) => 1496 - html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1848 + html` 1849 + <span class="character character-${c.charCodeAt(0)}">${c}</span> 1850 + ` 1497 1851 ); 1498 1852 1499 1853 const isPaused = !!audio && !this.isPlaying() && !this.#stopped.value; 1500 1854 1501 1855 // Playlist mini-time (current playback position) 1502 1856 const playlistMiniTimeChars = [...miniTimeStr].map((c, i) => 1503 - c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1857 + c === ":" ? null : html` 1858 + <span class="character character-${c.charCodeAt(0)}" style="left: ${i * 1859 + 5}px" 1860 + >${c}</span> 1861 + ` 1504 1862 ); 1505 1863 1506 1864 // Playlist shade: current track title + time ··· 1510 1868 ? `${shadeArtist} - ${nowTrack?.tags?.title ?? ""}`.toLowerCase() 1511 1869 : (nowTrack?.tags?.title ?? "").toLowerCase(); 1512 1870 const shadeTitleChars = [...shadeTitle].map((c) => 1513 - html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1871 + html` 1872 + <span class="character character-${c.charCodeAt(0)}">${c}</span> 1873 + ` 1514 1874 ); 1515 1875 const shadeTimeChars = [...miniTimeStr].map((c, i) => 1516 - c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1876 + c === ":" ? null : html` 1877 + <span class="character character-${c.charCodeAt(0)}" style="left: ${i * 1878 + 5}px" 1879 + >${c}</span> 1880 + ` 1517 1881 ); 1518 1882 1519 1883 const activeMarquee = this.#marqueeOverride.value ?? ··· 1583 1947 } 1584 1948 </style> 1585 1949 1586 - <div id="webamp" style="display: ${this.#mainOpen.value ? "block" : "none"}"> 1950 + <div id="webamp" style="display: ${this.#mainOpen.value 1951 + ? "block" 1952 + : "none"}"> 1587 1953 <div 1588 1954 id="main-window" 1589 - class="window ${this.#stopped.value ? "stop" : this.isPlaying() ? "play" : audio ? "pause" : "stop"}${this.#mainShade.value ? " shade" : ""}${focused === "main" ? " selected" : ""}" 1590 - style="position: absolute; top: ${this.#mainPos.y}px; left: ${this.#mainPos.x}px;" 1955 + class="window ${this.#stopped.value 1956 + ? "stop" 1957 + : this.isPlaying() 1958 + ? "play" 1959 + : audio 1960 + ? "pause" 1961 + : "stop"}${this.#mainShade.value 1962 + ? " shade" 1963 + : ""}${focused === "main" ? " selected" : ""}" 1964 + style="position: absolute; top: ${this.#mainPos.y}px; left: ${this 1965 + .#mainPos.x}px;" 1591 1966 > 1592 - <div id="title-bar" class="draggable" @dblclick="${this.#toggleMainShade}"> 1593 - <div id="option-context" @click="${this.#toggleMilkdrop}"><div id="option"></div></div> 1967 + <div id="title-bar" class="draggable" @dblclick="${this 1968 + .#toggleMainShade}"> 1969 + <div id="option-context" @click="${this.#toggleMilkdrop}"> 1970 + <div id="option"></div> 1971 + </div> 1594 1972 <div id="minimize"></div> 1595 1973 <div id="shade" @click="${this.#toggleMainShade}"></div> 1596 - ${this.#mainShade.value ? html`<div class="mini-time${isPaused ? " blinking" : ""}">${miniTimeChars}</div>` : ""} 1974 + ${this.#mainShade.value 1975 + ? html` 1976 + <div class="mini-time${isPaused 1977 + ? " blinking" 1978 + : ""}">${miniTimeChars}</div> 1979 + ` 1980 + : ""} 1597 1981 <div id="close" @click="${this.#closeMain}"></div> 1598 1982 </div> 1599 1983 <div class="webamp-status"> ··· 1618 2002 .sSecond}"></div> 1619 2003 </div> 1620 2004 </div> 1621 - <canvas id="visualizer" width="76" height="${this.#mainShade.value ? 5 : 16}"></canvas> 2005 + <canvas id="visualizer" width="76" height="${this.#mainShade.value 2006 + ? 5 2007 + : 16}"></canvas> 1622 2008 <div class="media-info"> 1623 2009 <div id="marquee"> 1624 2010 <div style="white-space: nowrap; will-change: transform; font-size: 0;"> ··· 1684 2070 1685 2071 <div 1686 2072 id="equalizer-window" 1687 - class="window${this.#eqShade.value ? " shade" : ""}${focused === "eq" ? " selected" : ""}" 1688 - style="position: absolute; top: ${this.#eqPos.y}px; left: ${this.#eqPos.x}px; display: ${this.#eqOpen.value ? "block" : "none"};" 2073 + class="window${this.#eqShade.value ? " shade" : ""}${focused === "eq" 2074 + ? " selected" 2075 + : ""}" 2076 + style="position: absolute; top: ${this.#eqPos.y}px; left: ${this 2077 + .#eqPos.x}px; display: ${this.#eqOpen.value ? "block" : "none"};" 1689 2078 > 1690 - ${this.#eqShade.value ? html` 1691 - <div class="draggable" style="width: 100%; height: 100%;" @dblclick="${this.#toggleEqShade}"> 1692 - <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 1693 - <div id="equalizer-close" @click="${this.#toggleEq}"></div> 1694 - <input type="range" id="equalizer-volume" class="${volumeClass}" min="0" max="100" value="${volumePct}" @input="${this.#onVolumeInput}"> 1695 - <input type="range" id="equalizer-balance" class="${balanceClass}" min="-100" max="100" value="${balance}" @input="${this.#onBalanceInput}"> 1696 - </div> 1697 - ` : html` 1698 - <div class="equalizer-top title-bar draggable" @dblclick="${this.#toggleEqShade}"> 1699 - <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 1700 - <div id="equalizer-close" @click="${this.#toggleEq}"></div> 1701 - </div> 1702 - <div id="on" class="${this.#eqOn.value ? "selected" : ""}" @click="${this.#toggleEqOn}"></div> 1703 - <div id="auto" @click="${this.#resetEq}"></div> 1704 - <canvas id="eqGraph" width="113" height="19"></canvas> 1705 - <div id="presets-context"><div id="presets"></div></div> 1706 - <div id="preamp">${bandSlider(preampVal, true)}</div> 1707 - <div id="preamp-line"></div> 1708 - <div id="plus12db"></div> 1709 - <div id="zerodb"></div> 1710 - <div id="minus12db"></div> 1711 - ${EQ_BANDS.map((hz, i) => 1712 - html`<div id="band-${hz}">${bandSlider(bandVals[i], false, i)}</div>` 1713 - )} 1714 - `} 2079 + ${this.#eqShade.value 2080 + ? html` 2081 + <div 2082 + class="draggable" 2083 + style="width: 100%; height: 100%;" 2084 + @dblclick="${this.#toggleEqShade}" 2085 + > 2086 + <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 2087 + <div id="equalizer-close" @click="${this.#toggleEq}"></div> 2088 + <input 2089 + type="range" 2090 + id="equalizer-volume" 2091 + class="${volumeClass}" 2092 + min="0" 2093 + max="100" 2094 + value="${volumePct}" 2095 + @input="${this.#onVolumeInput}" 2096 + > 2097 + <input 2098 + type="range" 2099 + id="equalizer-balance" 2100 + class="${balanceClass}" 2101 + min="-100" 2102 + max="100" 2103 + value="${balance}" 2104 + @input="${this.#onBalanceInput}" 2105 + > 2106 + </div> 2107 + ` 2108 + : html` 2109 + <div class="equalizer-top title-bar draggable" @dblclick="${this 2110 + .#toggleEqShade}"> 2111 + <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 2112 + <div id="equalizer-close" @click="${this.#toggleEq}"></div> 2113 + </div> 2114 + <div id="on" class="${this.#eqOn.value 2115 + ? "selected" 2116 + : ""}" @click="${this.#toggleEqOn}"></div> 2117 + <div id="auto" @click="${this.#resetEq}"></div> 2118 + <canvas id="eqGraph" width="113" height="19"></canvas> 2119 + <div id="presets-context"><div id="presets"></div></div> 2120 + <div id="preamp">${bandSlider(preampVal, true)}</div> 2121 + <div id="preamp-line"></div> 2122 + <div id="plus12db"></div> 2123 + <div id="zerodb"></div> 2124 + <div id="minus12db"></div> 2125 + ${EQ_BANDS.map((hz, i) => 2126 + html` 2127 + <div id="band-${hz}">${bandSlider( 2128 + bandVals[i], 2129 + false, 2130 + i, 2131 + )}</div> 2132 + ` 2133 + )} 2134 + `} 1715 2135 </div> 1716 2136 1717 - ${this.#playlistShade.value && this.#playlistOpen.value ? html` 1718 - <div 1719 - id="playlist-window-shade" 1720 - class="window draggable${focused === "playlist" ? " selected" : ""}" 1721 - style="position: absolute; top: ${this.#playlistPos.y}px; left: ${this.#playlistPos.x}px; width: ${this.#playlistSize.width}px;" 1722 - > 1723 - <div class="left"> 1724 - <div class="right draggable"> 1725 - <div id="playlist-shade-track-title">${shadeTitleChars}</div> 1726 - <div id="playlist-shade-time">${shadeTimeChars}</div> 1727 - <div id="playlist-shade-button" @click="${this.#togglePlaylistShade}"></div> 1728 - <div id="playlist-close-button" @click="${this.#togglePlaylist}"></div> 2137 + ${this.#playlistShade.value && this.#playlistOpen.value 2138 + ? html` 2139 + <div 2140 + id="playlist-window-shade" 2141 + class="window draggable${focused === "playlist" 2142 + ? " selected" 2143 + : ""}" 2144 + style="position: absolute; top: ${this.#playlistPos 2145 + .y}px; left: ${this.#playlistPos.x}px; width: ${this 2146 + .#playlistSize.width}px;" 2147 + > 2148 + <div class="left"> 2149 + <div class="right draggable"> 2150 + <div id="playlist-shade-track-title">${shadeTitleChars}</div> 2151 + <div id="playlist-shade-time">${shadeTimeChars}</div> 2152 + <div id="playlist-shade-button" @click="${this 2153 + .#togglePlaylistShade}"></div> 2154 + <div id="playlist-close-button" @click="${this 2155 + .#togglePlaylist}"></div> 2156 + </div> 2157 + </div> 1729 2158 </div> 1730 - </div> 1731 - </div> 1732 - ` : ""} 2159 + ` 2160 + : ""} 1733 2161 1734 2162 <div 1735 2163 id="playlist-window" 1736 2164 class="window${focused === "playlist" ? " selected" : ""}" 1737 - style="position: absolute; top: ${this.#playlistPos.y}px; left: ${this.#playlistPos.x}px; height: ${this.#playlistSize.height}px; width: ${this.#playlistSize.width}px; display: ${this.#playlistOpen.value && !this.#playlistShade.value ? "flex" : "none"};" 2165 + style="position: absolute; top: ${this.#playlistPos.y}px; left: ${this 2166 + .#playlistPos.x}px; height: ${this.#playlistSize 2167 + .height}px; width: ${this.#playlistSize 2168 + .width}px; display: ${this.#playlistOpen.value && 2169 + !this.#playlistShade.value 2170 + ? "flex" 2171 + : "none"};" 1738 2172 > 1739 - <div class="playlist-top draggable" @dblclick="${this.#togglePlaylistShade}"> 2173 + <div class="playlist-top draggable" @dblclick="${this 2174 + .#togglePlaylistShade}"> 1740 2175 <div class="playlist-top-left draggable"></div> 1741 2176 <div class="playlist-top-left-fill draggable"></div> 1742 2177 <div class="playlist-top-title draggable"></div> 1743 2178 <div class="playlist-top-right-fill draggable"></div> 1744 2179 <div class="playlist-top-right draggable"> 1745 - <div id="playlist-shade-button" @click="${this.#togglePlaylistShade}"></div> 1746 - <div id="playlist-close-button" @click="${this.#togglePlaylist}"></div> 2180 + <div id="playlist-shade-button" @click="${this 2181 + .#togglePlaylistShade}"></div> 2182 + <div id="playlist-close-button" @click="${this 2183 + .#togglePlaylist}"></div> 1747 2184 </div> 1748 2185 </div> 1749 2186 <div class="playlist-middle"> ··· 1752 2189 class="playlist-middle-center" 1753 2190 style="background-color: #000000; overflow-y: auto; font-family: Arial, sans-serif;" 1754 2191 > 1755 - <div class="playlist-tracks"> 1756 - <div class="playlist-track-titles"> 1757 - ${playlistRows.map((r) => 1758 - html` 1759 - <div 1760 - class="track-cell" 1761 - style="color: ${r.color}; background-color: ${r.bg};" 1762 - @click="${() => this.#selectTrack(r.idx)}" 1763 - @dblclick="${() => this.#playTrack(r.idx)}" 1764 - > 1765 - ${r.n}. ${r.label} 1766 - </div> 1767 - ` 1768 - )} 1769 - </div> 1770 - <div class="playlist-track-durations"> 1771 - ${playlistRows.map((r) => 1772 - html` 1773 - <div 1774 - class="track-cell" 1775 - style="color: ${r.color}; background-color: ${r.bg};" 1776 - @click="${() => this.#selectTrack(r.idx)}" 1777 - @dblclick="${() => this.#playTrack(r.idx)}" 1778 - > 1779 - ${r.dur} 1780 - </div> 1781 - ` 1782 - )} 1783 - </div> 1784 - </div> 2192 + ${guard([ 2193 + this.#playlist.value, 2194 + selectedIdx, 2195 + this.#dragState.value, 2196 + ], () => 2197 + html` 2198 + <div class="playlist-tracks"> 2199 + <div class="playlist-track-titles"> 2200 + ${repeat(playlistRows, (r) => r.id, (r) => 2201 + html` 2202 + <div 2203 + class="track-cell" 2204 + style="color: ${r.color}; background-color: ${r 2205 + .bg}; cursor: grab;" 2206 + @click="${() => this.#selectTrack(r.idx)}" 2207 + @dblclick="${() => this.#playTrack(r.idx)}" 2208 + @mousedown="${(/** @type {MouseEvent} */ e) => 2209 + this.#onTrackMouseDown( 2210 + e, 2211 + r.idx, 2212 + allItems.length, 2213 + )}" 2214 + > 2215 + ${r.n}. ${r.label} 2216 + </div> 2217 + `)} 2218 + </div> 2219 + <div class="playlist-track-durations"> 2220 + ${repeat(playlistRows, (r) => r.id, (r) => 2221 + html` 2222 + <div 2223 + class="track-cell" 2224 + style="color: ${r.color}; background-color: ${r 2225 + .bg}; cursor: grab;" 2226 + @click="${() => this.#selectTrack(r.idx)}" 2227 + @dblclick="${() => this.#playTrack(r.idx)}" 2228 + @mousedown="${(/** @type {MouseEvent} */ e) => 2229 + this.#onTrackMouseDown( 2230 + e, 2231 + r.idx, 2232 + allItems.length, 2233 + )}" 2234 + > 2235 + ${r.dur} 2236 + </div> 2237 + `)} 2238 + </div> 2239 + </div> 2240 + `)} 1785 2241 </div> 1786 2242 <div class="playlist-middle-right"> 1787 2243 <div class="playlist-scrollbar"> ··· 1791 2247 </div> 1792 2248 <div class="playlist-bottom"> 1793 2249 <div class="playlist-bottom-left"> 1794 - <div id="playlist-add-menu" class="playlist-menu" @click="${this.#openConnect}"></div> 2250 + <div id="playlist-add-menu" class="playlist-menu" @click="${this 2251 + .#openConnect}"> 2252 + </div> 1795 2253 <div id="playlist-remove-menu" class="playlist-menu"></div> 1796 2254 <div id="playlist-selection-menu" class="playlist-menu"></div> 1797 2255 <div id="playlist-misc-menu" class="playlist-menu"></div> ··· 1799 2257 <div class="playlist-bottom-center"></div> 1800 2258 <div class="playlist-bottom-right"> 1801 2259 <div class="playlist-running-time-display draggable">${totalTimeChars}</div> 1802 - <div class="mini-time${isPaused ? " blinking" : ""}">${playlistMiniTimeChars}</div> 2260 + <div class="mini-time${isPaused 2261 + ? " blinking" 2262 + : ""}">${playlistMiniTimeChars}</div> 1803 2263 <div class="playlist-action-buttons"> 1804 - <div class="playlist-previous-button" @click="${this.#previous}"></div> 1805 - <div class="playlist-play-button" @click="${this.#playPause}"></div> 1806 - <div class="playlist-pause-button" @click="${this.#playPause}"></div> 2264 + <div class="playlist-previous-button" @click="${this 2265 + .#previous}"></div> 2266 + <div class="playlist-play-button" @click="${this 2267 + .#playPause}"></div> 2268 + <div class="playlist-pause-button" @click="${this 2269 + .#playPause}"></div> 1807 2270 <div class="playlist-stop-button" @click="${this.#stop}"></div> 1808 2271 <div class="playlist-next-button" @click="${this.#next}"></div> 1809 - <div class="playlist-eject-button" @click="${this.#openConnect}"></div> 2272 + <div class="playlist-eject-button" @click="${this 2273 + .#openConnect}"></div> 1810 2274 </div> 1811 2275 <div id="playlist-list-menu" class="playlist-menu"></div> 1812 2276 <div id="playlist-resize-target"></div> ··· 1816 2280 1817 2281 <div 1818 2282 id="milkdrop-window" 1819 - class="window gen-window${this.#focusedWindow.value === "milkdrop" ? " selected" : ""}" 1820 - style="position: absolute; top: ${this.#milkdropPos.y}px; left: ${this.#milkdropPos.x}px; width: ${this.#milkdropSize.width}px; height: ${this.#milkdropSize.height}px; display: ${this.#milkdropOpen.value ? "flex" : "none"};" 2283 + class="window gen-window${this.#focusedWindow.value === "milkdrop" 2284 + ? " selected" 2285 + : ""}" 2286 + style="position: absolute; top: ${this.#milkdropPos.y}px; left: ${this 2287 + .#milkdropPos.x}px; width: ${this.#milkdropSize 2288 + .width}px; height: ${this.#milkdropSize 2289 + .height}px; display: ${this.#milkdropOpen.value ? "flex" : "none"};" 1821 2290 > 1822 2291 <div class="gen-top draggable"> 1823 2292 <div class="gen-top-left draggable"></div> 1824 2293 <div class="gen-top-left-fill draggable"></div> 1825 2294 <div class="gen-top-left-end draggable"></div> 1826 2295 <div class="gen-top-title draggable"> 1827 - ${"MILKDROP".split("").map((c) => html`<div class="draggable gen-text-letter gen-text-${c.toLowerCase()}"></div>`)} 2296 + ${"MILKDROP".split("").map((c) => 2297 + html` 2298 + <div class="draggable gen-text-letter gen-text-${c 2299 + .toLowerCase()}"></div> 2300 + ` 2301 + )} 1828 2302 </div> 1829 2303 <div class="gen-top-right-end draggable"></div> 1830 2304 <div class="gen-top-right-fill draggable"></div> 1831 2305 <div class="gen-top-right draggable"> 1832 - <div class="gen-close selected" @click="${this.#toggleMilkdrop}"></div> 2306 + <div class="gen-close selected" @click="${this 2307 + .#toggleMilkdrop}"></div> 1833 2308 </div> 1834 2309 </div> 1835 2310 <div class="gen-middle"> ··· 1837 2312 <div class="gen-middle-left-bottom draggable"></div> 1838 2313 </div> 1839 2314 <div class="gen-middle-center" style="background: #000;"> 1840 - <canvas id="milkdrop-canvas" style="position: absolute; inset: 0; width: 100%; height: 100%;"></canvas> 2315 + <canvas 2316 + id="milkdrop-canvas" 2317 + style="position: absolute; inset: 0; width: 100%; height: 100%;" 2318 + @dblclick="${this.#toggleMilkdropFullscreen}" 2319 + ></canvas> 1841 2320 </div> 1842 2321 <div class="gen-middle-right draggable"> 1843 2322 <div class="gen-middle-right-bottom draggable"></div> ··· 1850 2329 </div> 1851 2330 </div> 1852 2331 </div> 1853 - 1854 2332 </div> 1855 2333 `; 1856 2334 }
+85
tests/components/engine/queue/test.ts
··· 163 163 expect(count).toBe(3); 164 164 }); 165 165 166 + it("moves a future item to a new position", async () => { 167 + const ids = await testWeb(async () => { 168 + const QueueEngine = await import("~/components/engine/queue/element.js"); 169 + const engine = new QueueEngine.CLASS(); 170 + 171 + document.body.append(engine); 172 + 173 + const { tracks } = await import("~/testing/sample/tracks.js"); 174 + 175 + await engine.add({ trackIds: tracks.slice(0, 3).map((t) => t.id) }); 176 + await engine.move({ from: 0, to: 2 }); 177 + return engine.future().map((i) => i.id); 178 + }); 179 + 180 + expect(ids[0]).toBe(tracks[1].id); 181 + expect(ids[1]).toBe(tracks[2].id); 182 + expect(ids[2]).toBe(tracks[0].id); 183 + }); 184 + 185 + it("move preserves now when reordering around it", async () => { 186 + const result = await testWeb(async () => { 187 + const QueueEngine = await import("~/components/engine/queue/element.js"); 188 + const engine = new QueueEngine.CLASS(); 189 + 190 + document.body.append(engine); 191 + 192 + const { tracks } = await import("~/testing/sample/tracks.js"); 193 + 194 + await engine.add({ trackIds: tracks.slice(0, 3).map((t) => t.id) }); 195 + await engine.shift(); 196 + // flat list: [past[0]=tracks[0]], now=tracks[1], future=[tracks[2]] 197 + // move future item (idx 2) before now (idx 1) 198 + await engine.move({ from: 2, to: 0 }); 199 + 200 + return { 201 + now: engine.now()?.id, 202 + past: engine.past().map((i) => i.id), 203 + future: engine.future().map((i) => i.id), 204 + }; 205 + }); 206 + 207 + // shift() moved tracks[0] to now; move({ from:2, to:0 }) put tracks[2] 208 + // before now, so: past=[tracks[2]], now=tracks[0], future=[tracks[1]] 209 + expect(result.now).toBe(tracks[0].id); 210 + expect(result.past[0]).toBe(tracks[2].id); 211 + expect(result.future[0]).toBe(tracks[1].id); 212 + }); 213 + 214 + it("move does nothing when from === to", async () => { 215 + const ids = await testWeb(async () => { 216 + const QueueEngine = await import("~/components/engine/queue/element.js"); 217 + const engine = new QueueEngine.CLASS(); 218 + 219 + document.body.append(engine); 220 + 221 + const { tracks } = await import("~/testing/sample/tracks.js"); 222 + 223 + await engine.add({ trackIds: tracks.slice(0, 3).map((t) => t.id) }); 224 + await engine.move({ from: 1, to: 1 }); 225 + return engine.future().map((i) => i.id); 226 + }); 227 + 228 + expect(ids[0]).toBe(tracks[0].id); 229 + expect(ids[1]).toBe(tracks[1].id); 230 + expect(ids[2]).toBe(tracks[2].id); 231 + }); 232 + 233 + it("move does nothing for out-of-bounds indices", async () => { 234 + const ids = await testWeb(async () => { 235 + const QueueEngine = await import("~/components/engine/queue/element.js"); 236 + const engine = new QueueEngine.CLASS(); 237 + 238 + document.body.append(engine); 239 + 240 + const { tracks } = await import("~/testing/sample/tracks.js"); 241 + 242 + await engine.add({ trackIds: tracks.slice(0, 2).map((t) => t.id) }); 243 + await engine.move({ from: 0, to: 99 }); 244 + return engine.future().map((i) => i.id); 245 + }); 246 + 247 + expect(ids[0]).toBe(tracks[0].id); 248 + expect(ids[1]).toBe(tracks[1].id); 249 + }); 250 + 166 251 it("[shared worker] has the correct past", async () => { 167 252 const item = await testWeb(async () => { 168 253 const QueueEngine = await import("~/components/engine/queue/element.js");