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

Configure Feed

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

wip: more winamp theme work

+1087 -88
+1
src/themes/winamp/facet/index.inline.js
··· 2 2 import { effect } from "~/common/signal.js"; 3 3 4 4 import WindowManager from "~/themes/winamp/window-manager/element.js"; 5 + import WinampElement from "~/themes/winamp/winamp/element.js"; 5 6 6 7 // Set doc title 7 8 foundation.setup({ title: "Winamp | Diffuse" });
+1086 -88
src/themes/winamp/winamp/element.js
··· 20 20 21 21 const UI_STATE_KEY = "themes/winamp/winamp/ui"; 22 22 23 - /** @returns {{ eqOpen: boolean, playlistOpen: boolean, eqOn: boolean, eqSliders: Record<string, number> | null }} */ 23 + /** @returns {{ eqOpen: boolean, playlistOpen: boolean, eqOn: boolean, eqSliders: Record<string, number> | null, mainShade: boolean, eqShade: boolean, playlistShade: boolean }} */ 24 24 function loadUiState() { 25 25 try { 26 26 return { ··· 28 28 playlistOpen: true, 29 29 eqOn: false, 30 30 eqSliders: null, 31 + mainShade: false, 32 + eqShade: false, 33 + playlistShade: false, 31 34 ...JSON.parse(localStorage.getItem(UI_STATE_KEY) ?? "{}"), 32 35 }; 33 36 } catch { 34 - return { eqOpen: true, playlistOpen: true, eqOn: false, eqSliders: null }; 37 + return { eqOpen: true, playlistOpen: true, eqOn: false, eqSliders: null, mainShade: false, eqShade: false, playlistShade: false }; 35 38 } 36 39 } 37 40 ··· 48 51 // CONSTANTS 49 52 //////////////////////////////////////////// 50 53 54 + /** 55 + * Webamp's Nullsoft FFT — ported from webamp.bundle.js 56 + * Converts 1024 time-domain samples → 512 spectral magnitude values. 57 + */ 58 + class WinampFFT { 59 + static #TWO_PI = 6.2831853; 60 + static #HALF_PI = 1.5707963268; 61 + 62 + constructor() { 63 + const NFREQ = 1024; // samplesOut * 2 64 + this.#bitrev = this.#initBitRev(NFREQ); 65 + this.#cossint = this.#initCosSin(NFREQ); 66 + this.#envelope = this.#initEnvelope(1024, 1.0); 67 + this.#equalize = this.#initEqualize(NFREQ); 68 + this.#temp1 = new Float32Array(NFREQ); 69 + this.#temp2 = new Float32Array(NFREQ); 70 + } 71 + 72 + /** @type {number[]} */ #bitrev; 73 + /** @type {Float32Array[]} */ #cossint; 74 + /** @type {Float32Array} */ #envelope; 75 + /** @type {Float32Array} */ #equalize; 76 + /** @type {Float32Array} */ #temp1; 77 + /** @type {Float32Array} */ #temp2; 78 + 79 + #initBitRev(NFREQ) { 80 + const t = Array.from({ length: NFREQ }, (_, i) => i); 81 + for (let i = 0, j = 0; i < NFREQ; i++) { 82 + if (j > i) { const tmp = t[i]; t[i] = t[j]; t[j] = tmp; } 83 + let m = NFREQ >> 1; 84 + while (m >= 1 && j >= m) { j -= m; m >>= 1; } 85 + j += m; 86 + } 87 + return t; 88 + } 89 + 90 + #initCosSin(NFREQ) { 91 + const table = []; 92 + let d = 2; 93 + while (d <= NFREQ) { 94 + const theta = (-2.0 * Math.PI) / d; 95 + table.push(new Float32Array([Math.cos(theta), Math.sin(theta)])); 96 + d <<= 1; 97 + } 98 + return table; 99 + } 100 + 101 + #initEnvelope(n, power) { 102 + const mult = (1.0 / n) * WinampFFT.#TWO_PI; 103 + return Float32Array.from({ length: n }, (_, i) => 104 + Math.pow(0.5 + 0.5 * Math.sin(i * mult - WinampFFT.#HALF_PI), power) 105 + ); 106 + } 107 + 108 + #initEqualize(NFREQ) { 109 + const eq = new Float32Array(NFREQ / 2); 110 + let bias = 0.04; 111 + for (let i = 0; i < NFREQ / 2; i++) { 112 + const inv = (9.0 - bias) / (NFREQ / 2); 113 + eq[i] = Math.log10(1.0 + bias + (i + 1) * inv); 114 + bias /= 1.0025; 115 + } 116 + return eq; 117 + } 118 + 119 + /** 120 + * @param {Float32Array} inWave 1024 samples 121 + * @param {Float32Array} outSpec 512 output magnitudes 122 + */ 123 + timeToFrequencyDomain(inWave, outSpec) { 124 + const real = this.#temp1; 125 + const imag = this.#temp2; 126 + const bitrev = this.#bitrev; 127 + const env = this.#envelope; 128 + 129 + for (let i = 0; i < real.length; i++) { 130 + const idx = bitrev[i]; 131 + real[i] = idx < inWave.length ? inWave[idx] * env[idx] : 0; 132 + } 133 + imag.fill(0); 134 + 135 + let dftsize = 2, t = 0; 136 + while (dftsize <= real.length) { 137 + const [wpr, wpi] = this.#cossint[t]; 138 + let wr = 1.0, wi = 0.0; 139 + const half = dftsize >> 1; 140 + for (let m = 0; m < half; m++) { 141 + for (let i = m; i < real.length; i += dftsize) { 142 + const j = i + half; 143 + const tr = wr * real[j] - wi * imag[j]; 144 + const ti = wr * imag[j] + wi * real[j]; 145 + real[j] = real[i] - tr; imag[j] = imag[i] - ti; 146 + real[i] += tr; imag[i] += ti; 147 + } 148 + const wt = wr; 149 + wr = wr * wpr - wi * wpi; 150 + wi = wi * wpr + wt * wpi; 151 + } 152 + dftsize <<= 1; t++; 153 + } 154 + 155 + const eq = this.#equalize; 156 + for (let i = 0; i < outSpec.length; i++) { 157 + outSpec[i] = Math.sqrt(real[i] * real[i] + imag[i] * imag[i]) * eq[i]; 158 + } 159 + } 160 + } 161 + 51 162 class WinampElement extends DiffuseElement { 52 163 constructor() { 53 164 super(); ··· 62 173 #marqueeCurrentOffset = 0; 63 174 /** @type {HTMLElement | null} */ 64 175 #marqueeScroller = null; 176 + /** @type {HTMLElement | null} */ 177 + #playlistHandle = null; 178 + 179 + // Web Audio / Visualizer 180 + /** @type {AudioContext | null} */ 181 + #audioCtx = null; 182 + /** @type {GainNode | null} */ 183 + #preampNode = null; 184 + /** @type {BiquadFilterNode[]} */ 185 + #eqNodes = []; 186 + /** @type {AnalyserNode | null} */ 187 + #analyser = null; 188 + /** @type {Map<string, MediaElementAudioSourceNode>} */ 189 + #srcNodes = new Map(); 190 + /** @type {number | undefined} */ 191 + #visRAF = undefined; 65 192 /** @type {ReturnType<typeof setInterval> | undefined} */ 66 193 #marqueeStepInterval = undefined; 67 194 #marqueeText = signal(""); 68 195 #selectedTrackId = signal(/** @type {string | null} */ (null)); 196 + #mainOpen = signal(true); 69 197 #eqOpen = signal(true); 198 + #mainShade = signal(false); 199 + #eqShade = signal(false); 200 + #playlistShade = signal(false); 201 + #eqOn = signal(false); 202 + #eqSliders = signal({ preamp: 50, bands: /** @type {number[]} */ (Array(10).fill(50)) }); 203 + #balance = signal(0); 204 + #stopped = signal(false); 205 + #seekingProgress = signal(/** @type {number | null} */ (null)); 206 + #focusedWindow = signal(/** @type {"main" | "eq" | "playlist"} */ ("main")); 70 207 #playlistOpen = signal(true); 71 208 209 + // Window positions — plain objects (not signals) so dragging doesn't 210 + // trigger re-renders, but the current value is always read on each render. 211 + #mainPos = { x: 0, y: 0 }; 212 + #eqPos = { x: 0, y: 116 }; 213 + #playlistPos = { x: 0, y: 232 }; 214 + #playlistSize = { width: 275, height: 232 }; 215 + 72 216 // SIGNALS - DEPENDENCIES 73 217 74 218 $audio = signal(/** @type {AudioEngine | undefined} */ (undefined)); ··· 140 284 })`; 141 285 this.#marqueeCurrentOffset = 0; 142 286 }); 287 + 288 + this.effect(() => { 289 + const audioId = this.$queue.value?.now()?.id; 290 + const playing = this.isPlaying(); 291 + untracked(() => { 292 + if (audioId) this.#connectToAnalyser(audioId); 293 + if (playing) { 294 + this.#ensureAnalyser(); 295 + this.#audioCtx?.resume(); 296 + this.#startVisualizer(); 297 + } 298 + }); 299 + }); 143 300 }); 144 301 145 302 // UI State 146 303 const ui = loadUiState(); 147 304 this.#eqOpen.value = ui.eqOpen; 305 + this.#eqOn.value = ui.eqOn; 306 + this.#mainShade.value = ui.mainShade; 307 + this.#eqShade.value = ui.eqShade; 308 + this.#playlistShade.value = ui.playlistShade; 148 309 this.#playlistOpen.value = ui.playlistOpen; 310 + if (ui.eqSliders) { 311 + const s = ui.eqSliders; 312 + this.#eqSliders.value = { 313 + preamp: s["preamp"] ?? 50, 314 + bands: EQ_BANDS.map((_, i) => s[`band_${i}`] ?? 50), 315 + }; 316 + } 317 + 318 + // Center the windows on startup 319 + const totalH = 116 + 116 + this.#playlistSize.height; 320 + const cx = Math.round((window.innerWidth - 275) / 2); 321 + const cy = Math.round((window.innerHeight - totalH) / 2); 322 + this.#mainPos.x = cx; 323 + this.#mainPos.y = cy; 324 + this.#eqPos.x = cx; 325 + this.#eqPos.y = cy + 116; 326 + this.#playlistPos.x = cx; 327 + this.#playlistPos.y = cy + 232; 149 328 150 329 this.forceRender(); 151 330 this.#marqueeScroller = this.root().querySelector("#marquee > div"); 152 - requestAnimationFrame(() => this.#drawEqGraph()); 331 + this.#playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 332 + requestAnimationFrame(() => { 333 + this.#drawEqGraph(); 334 + this.#updatePlaylistHandle(); 335 + }); 336 + 337 + // Custom playlist scrollbar 338 + const playlistContent = this.root().querySelector(".playlist-middle-center"); 339 + const playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 340 + const playlistScrollbar = this.root().querySelector(".playlist-scrollbar"); 341 + if (playlistContent instanceof HTMLElement) { 342 + playlistContent.addEventListener("scroll", () => this.#updatePlaylistHandle()); 343 + } 344 + if ( 345 + playlistHandle instanceof HTMLElement && 346 + playlistScrollbar instanceof HTMLElement && 347 + playlistContent instanceof HTMLElement 348 + ) { 349 + playlistHandle.addEventListener("mousedown", (e) => { 350 + e.preventDefault(); 351 + const TRACK_H = 13; 352 + const HANDLE_H = 18; 353 + const range = playlistScrollbar.clientHeight - HANDLE_H; 354 + const startY = e.clientY; 355 + const startTop = parseFloat(playlistHandle.style.top) || 0; 356 + const maxScroll = playlistContent.scrollHeight - playlistContent.clientHeight; 357 + const onMove = (mv) => { 358 + const newTop = Math.max(0, Math.min(range, startTop + mv.clientY - startY)); 359 + const rawScroll = (newTop / range) * maxScroll; 360 + playlistContent.scrollTop = Math.round(rawScroll / TRACK_H) * TRACK_H; 361 + }; 362 + const onUp = () => { 363 + document.removeEventListener("mousemove", onMove); 364 + document.removeEventListener("mouseup", onUp); 365 + }; 366 + document.addEventListener("mousemove", onMove); 367 + document.addEventListener("mouseup", onUp); 368 + }); 369 + } 153 370 154 371 this.#marqueeStepInterval = setInterval(() => { 155 372 const text = untracked( ··· 176 393 } 177 394 }, 220); 178 395 179 - // winamp-active press feedback via event delegation 396 + // winamp-active press feedback + window focus via event delegation 180 397 this.root().addEventListener("pointerdown", (e) => { 181 398 if (!(e.target instanceof HTMLElement)) return; 399 + // Window focus 400 + const win = e.target.closest("#main-window, #equalizer-window, #playlist-window, #playlist-window-shade"); 401 + if (win instanceof HTMLElement) { 402 + if (win.id === "main-window") this.#focusedWindow.value = "main"; 403 + else if (win.id === "equalizer-window") this.#focusedWindow.value = "eq"; 404 + else this.#focusedWindow.value = "playlist"; 405 + } 406 + // Press feedback 182 407 if (e.target.tagName !== "DIV" || !e.target.id) return; 183 408 const el = e.target; 184 409 el.classList.add("winamp-active"); ··· 188 413 }; 189 414 document.addEventListener("pointerup", cleanup); 190 415 }); 416 + 417 + // Window dragging 418 + this.root().addEventListener("mousedown", this.#onWindowDragStart); 191 419 } 192 420 193 421 disconnectedCallback() { 194 422 clearInterval(this.#marqueeStepInterval); 423 + this.root().removeEventListener("mousedown", this.#onWindowDragStart); 424 + if (this.#visRAF !== undefined) { 425 + cancelAnimationFrame(this.#visRAF); 426 + this.#visRAF = undefined; 427 + } 428 + } 429 + 430 + // WINDOW SNAPPING — ported from webamp/js/snapUtils.ts 431 + 432 + static #SNAP_DISTANCE = 15; 433 + 434 + /** @param {number} a @param {number} b */ 435 + static #near(a, b) { 436 + return Math.abs(a - b) < WinampElement.#SNAP_DISTANCE; 437 + } 438 + 439 + /** 440 + * @param {{ x: number, y: number, width: number, height: number }} a 441 + * @param {{ x: number, y: number, width: number, height: number }} b 442 + */ 443 + static #overlapX(a, b) { 444 + const D = WinampElement.#SNAP_DISTANCE; 445 + return a.x <= b.x + b.width + D && b.x <= a.x + a.width + D; 446 + } 447 + 448 + /** 449 + * @param {{ x: number, y: number, width: number, height: number }} a 450 + * @param {{ x: number, y: number, width: number, height: number }} b 451 + */ 452 + static #overlapY(a, b) { 453 + const D = WinampElement.#SNAP_DISTANCE; 454 + return a.y <= b.y + b.height + D && b.y <= a.y + a.height + D; 455 + } 456 + 457 + /** 458 + * Returns snapped position of `a` toward `b`, if close enough. 459 + * @param {{ x: number, y: number, width: number, height: number }} a 460 + * @param {{ x: number, y: number, width: number, height: number }} b 461 + */ 462 + static #snapTo(a, b) { 463 + const near = WinampElement.#near; 464 + let x, y; 465 + if (WinampElement.#overlapY(a, b)) { 466 + if (near(a.x, b.x + b.width)) x = b.x + b.width; 467 + else if (near(a.x + a.width, b.x)) x = b.x - a.width; 468 + else if (near(a.x, b.x)) x = b.x; 469 + else if (near(a.x + a.width, b.x + b.width)) x = b.x + b.width - a.width; 470 + } 471 + if (WinampElement.#overlapX(a, b)) { 472 + if (near(a.y, b.y + b.height)) y = b.y + b.height; 473 + else if (near(a.y + a.height, b.y)) y = b.y - a.height; 474 + else if (near(a.y, b.y)) y = b.y; 475 + else if (near(a.y + a.height, b.y + b.height)) y = b.y + b.height - a.height; 476 + } 477 + return { x, y }; 478 + } 479 + 480 + /** 481 + * @param {{ x: number, y: number, width: number, height: number }} a 482 + * @param {{ x: number, y: number, width: number, height: number }[]} others 483 + */ 484 + static #snapToMany(a, others) { 485 + let x, y; 486 + for (const b of others) { 487 + const s = WinampElement.#snapTo(a, b); 488 + x ??= s.x; 489 + y ??= s.y; 490 + } 491 + return { x, y }; 492 + } 493 + 494 + /** 495 + * Two windows are "attached" if their edges are already touching (≤1px). 496 + * @param {{ x: number, y: number, width: number, height: number }} a 497 + * @param {{ x: number, y: number, width: number, height: number }} b 498 + */ 499 + static #areTouching(a, b) { 500 + const T = 1; 501 + const hTouch = Math.abs(a.x - (b.x + b.width)) <= T || Math.abs(b.x - (a.x + a.width)) <= T; 502 + const vTouch = Math.abs(a.y - (b.y + b.height)) <= T || Math.abs(b.y - (a.y + a.height)) <= T; 503 + const oX = a.x < b.x + b.width + T && b.x < a.x + a.width + T; 504 + const oY = a.y < b.y + b.height + T && b.y < a.y + a.height + T; 505 + return (hTouch && oY) || (vTouch && oX); 506 + } 507 + 508 + // WINDOW DRAGGING & RESIZING 509 + 510 + /** @param {MouseEvent} e */ 511 + #onWindowDragStart = (e) => { 512 + if (!(e.target instanceof HTMLElement)) return; 513 + 514 + // Resize handle takes priority 515 + if (e.target.id === "playlist-resize-target") { 516 + this.#onPlaylistResizeStart(e); 517 + return; 518 + } 519 + 520 + const draggable = e.target.closest(".draggable"); 521 + if (!draggable) return; 522 + const win = draggable.closest( 523 + "#main-window, #equalizer-window, #playlist-window", 524 + ); 525 + if (!(win instanceof HTMLElement)) return; 526 + 527 + e.preventDefault(); 528 + 529 + const allWindows = this.#windowEntries(); 530 + const draggedEntry = allWindows.find((w) => w.el === win); 531 + if (!draggedEntry) return; 532 + 533 + // Only the main window drags attached windows along with it 534 + /** @type {Set<typeof allWindows[0]>} */ 535 + const attached = new Set(); 536 + if (win.id === "main-window") { 537 + const trace = (/** @type {typeof allWindows[0]} */ entry) => { 538 + for (const other of allWindows) { 539 + if (other === entry || other === draggedEntry || attached.has(other)) continue; 540 + if (WinampElement.#areTouching(entry.box(), other.box())) { 541 + attached.add(other); 542 + trace(other); 543 + } 544 + } 545 + }; 546 + trace(draggedEntry); 547 + } 548 + 549 + const startMouseX = e.clientX; 550 + const startMouseY = e.clientY; 551 + const startPos = { x: draggedEntry.pos.x, y: draggedEntry.pos.y }; 552 + const attachedStarts = [...attached].map((w) => ({ w, x: w.pos.x, y: w.pos.y })); 553 + const snapTargets = allWindows 554 + .filter((w) => w !== draggedEntry && !attached.has(w)) 555 + .map((w) => w.box()); 556 + 557 + /** @param {MouseEvent} mv */ 558 + const onMove = (mv) => { 559 + const dx = mv.clientX - startMouseX; 560 + const dy = mv.clientY - startMouseY; 561 + const newX = startPos.x + dx; 562 + const newY = startPos.y + dy; 563 + 564 + const snapped = WinampElement.#snapToMany( 565 + { ...draggedEntry.box(), x: newX, y: newY }, 566 + snapTargets, 567 + ); 568 + const finalX = snapped.x ?? newX; 569 + const finalY = snapped.y ?? newY; 570 + 571 + draggedEntry.pos.x = finalX; 572 + draggedEntry.pos.y = finalY; 573 + win.style.left = `${finalX}px`; 574 + win.style.top = `${finalY}px`; 575 + 576 + const snapDx = finalX - newX; 577 + const snapDy = finalY - newY; 578 + for (const { w, x: sx, y: sy } of attachedStarts) { 579 + w.pos.x = sx + dx + snapDx; 580 + w.pos.y = sy + dy + snapDy; 581 + w.el.style.left = `${w.pos.x}px`; 582 + w.el.style.top = `${w.pos.y}px`; 583 + } 584 + }; 585 + 586 + const onUp = () => { 587 + document.removeEventListener("mousemove", onMove); 588 + document.removeEventListener("mouseup", onUp); 589 + }; 590 + 591 + document.addEventListener("mousemove", onMove); 592 + document.addEventListener("mouseup", onUp); 593 + }; 594 + 595 + /** @param {MouseEvent} e */ 596 + #onPlaylistResizeStart = (e) => { 597 + e.preventDefault(); 598 + 599 + const playlistEl = this.root().querySelector("#playlist-window"); 600 + if (!(playlistEl instanceof HTMLElement)) return; 601 + 602 + const startMouseX = e.clientX; 603 + const startMouseY = e.clientY; 604 + const startWidth = this.#playlistSize.width; 605 + const startHeight = this.#playlistSize.height; 606 + 607 + // Webamp resize segment: 25px wide, 29px tall 608 + const STEP_W = 25, STEP_H = 29, MIN_W = 275, MIN_H = 116; 609 + 610 + /** @param {MouseEvent} mv */ 611 + const onMove = (mv) => { 612 + const newWidth = Math.max( 613 + MIN_W, 614 + startWidth + Math.round((mv.clientX - startMouseX) / STEP_W) * STEP_W, 615 + ); 616 + const newHeight = Math.max( 617 + MIN_H, 618 + startHeight + Math.round((mv.clientY - startMouseY) / STEP_H) * STEP_H, 619 + ); 620 + this.#playlistSize.width = newWidth; 621 + this.#playlistSize.height = newHeight; 622 + playlistEl.style.width = `${newWidth}px`; 623 + playlistEl.style.height = `${newHeight}px`; 624 + }; 625 + 626 + const onUp = () => { 627 + document.removeEventListener("mousemove", onMove); 628 + document.removeEventListener("mouseup", onUp); 629 + }; 630 + 631 + document.addEventListener("mousemove", onMove); 632 + document.addEventListener("mouseup", onUp); 633 + }; 634 + 635 + #updatePlaylistHandle() { 636 + if (!this.#playlistHandle?.isConnected) { 637 + this.#playlistHandle = this.root().querySelector(".playlist-scrollbar-handle"); 638 + } 639 + const handle = this.#playlistHandle; 640 + const content = this.root().querySelector(".playlist-middle-center"); 641 + const scrollbar = this.root().querySelector(".playlist-scrollbar"); 642 + if (!handle || !(content instanceof HTMLElement) || !(scrollbar instanceof HTMLElement)) return; 643 + 644 + const HANDLE_H = 18; 645 + const range = Math.max(0, scrollbar.clientHeight - HANDLE_H); 646 + const scrollFraction = content.scrollHeight > content.clientHeight 647 + ? content.scrollTop / (content.scrollHeight - content.clientHeight) 648 + : 0; 649 + handle.style.top = `${scrollFraction * range}px`; 650 + } 651 + 652 + /** 653 + * Returns live box descriptors for all three windows. 654 + */ 655 + #windowEntries() { 656 + const root = this.root(); 657 + const ps = this.#playlistSize; 658 + return [ 659 + { 660 + el: /** @type {HTMLElement} */ (root.querySelector("#main-window")), 661 + pos: this.#mainPos, 662 + box: () => ({ x: this.#mainPos.x, y: this.#mainPos.y, width: 275, height: this.#mainShade.value ? 14 : 116 }), 663 + }, 664 + { 665 + el: /** @type {HTMLElement} */ (root.querySelector("#equalizer-window")), 666 + pos: this.#eqPos, 667 + box: () => ({ x: this.#eqPos.x, y: this.#eqPos.y, width: 275, height: this.#eqShade.value ? 14 : 116 }), 668 + }, 669 + { 670 + el: /** @type {HTMLElement} */ (root.querySelector("#playlist-window")), 671 + pos: this.#playlistPos, 672 + box: () => ({ x: this.#playlistPos.x, y: this.#playlistPos.y, width: ps.width, height: ps.height }), 673 + }, 674 + ]; 195 675 } 196 676 197 677 // MARQUEE ··· 211 691 : text.padEnd(MAX, " "); 212 692 } 213 693 694 + // SPECTRUM VISUALIZER 695 + 696 + // Webamp default viscolors[0..15]: y=0 top (black), y=2 loud (red), y=15 quiet (green) 697 + static #BAR_COLORS = [ 698 + "rgb(0,0,0)", // 0 — black (never visible) 699 + "rgb(24,33,41)", // 1 — grid dot color (never visible with pushDown=2) 700 + "rgb(239,49,16)", // 2 — bright red (loud) 701 + "rgb(206,41,16)", // 3 702 + "rgb(214,90,0)", // 4 703 + "rgb(214,102,0)", // 5 704 + "rgb(214,115,0)", // 6 705 + "rgb(198,123,8)", // 7 706 + "rgb(222,165,24)", // 8 707 + "rgb(214,181,33)", // 9 708 + "rgb(189,222,41)", // 10 709 + "rgb(148,222,33)", // 11 710 + "rgb(41,206,16)", // 12 711 + "rgb(50,190,16)", // 13 712 + "rgb(57,181,16)", // 14 713 + "rgb(49,156,8)", // 15 — dim green (quiet) 714 + ]; 715 + 716 + #ensureAnalyser() { 717 + if (this.#analyser) return; 718 + this.#audioCtx = new AudioContext(); 719 + 720 + this.#preampNode = this.#audioCtx.createGain(); 721 + this.#eqNodes = EQ_BANDS.map((freq) => { 722 + const f = /** @type {AudioContext} */ (this.#audioCtx).createBiquadFilter(); 723 + f.type = "peaking"; 724 + f.frequency.value = freq; 725 + f.Q.value = 1.0; 726 + f.gain.value = 0; 727 + return f; 728 + }); 729 + this.#analyser = this.#audioCtx.createAnalyser(); 730 + this.#analyser.fftSize = 1024; 731 + this.#analyser.smoothingTimeConstant = 0; 732 + 733 + // Chain: source → preamp → eq[0..9] → analyser → destination 734 + this.#preampNode.connect(this.#eqNodes[0]); 735 + for (let i = 0; i < this.#eqNodes.length - 1; i++) { 736 + this.#eqNodes[i].connect(this.#eqNodes[i + 1]); 737 + } 738 + this.#eqNodes[this.#eqNodes.length - 1].connect(this.#analyser); 739 + this.#analyser.connect(this.#audioCtx.destination); 740 + 741 + this.#applyEq(); 742 + } 743 + 744 + /** @param {string} audioId */ 745 + #connectToAnalyser(audioId) { 746 + if (this.#srcNodes.has(audioId)) return; 747 + const audioEl = this.$audio.value 748 + ?.querySelector(`de-audio-item[id="${audioId}"]:not([preload]) audio`); 749 + if (!(audioEl instanceof HTMLAudioElement)) return; 750 + this.#ensureAnalyser(); 751 + const src = /** @type {AudioContext} */ (this.#audioCtx) 752 + .createMediaElementSource(audioEl); 753 + src.connect(/** @type {GainNode} */ (this.#preampNode)); 754 + this.#srcNodes.set(audioId, src); 755 + } 756 + 757 + #startVisualizer() { 758 + if (this.#visRAF !== undefined) return; 759 + const canvas = this.root().querySelector("#visualizer"); 760 + if (!(canvas instanceof HTMLCanvasElement)) return; 761 + const ctx = canvas.getContext("2d"); 762 + if (!ctx || !this.#analyser) return; 763 + 764 + ctx.imageSmoothingEnabled = false; 765 + 766 + const MAX_WIDTH = 75; 767 + const FALL_RATE = 12 / 16; 768 + const PEAK_FALLOFF = 1.1; 769 + const analyser = this.#analyser; 770 + 771 + // Webamp FFT (Nullsoft implementation) 772 + const fft = new WinampFFT(); 773 + const inWaveData = new Float32Array(1024); 774 + const outSpectralData = new Float32Array(512); 775 + const timeDomainBuf = new Uint8Array(1024); 776 + 777 + // Per-bar state (matches webamp's BarPaintHandler) 778 + const sample = new Float32Array(MAX_WIDTH); 779 + const saFalloff = new Float32Array(MAX_WIDTH); 780 + const saPeaks = new Int16Array(MAX_WIDTH); 781 + const saData2 = new Float32Array(MAX_WIDTH); 782 + const barPeak = new Float32Array(MAX_WIDTH); 783 + 784 + // Cached off-screen canvases — rebuilt when canvas dimensions change 785 + const bgCanvas = document.createElement("canvas"); 786 + const bgCtx = bgCanvas.getContext("2d"); 787 + const gradCanvas = document.createElement("canvas"); 788 + gradCanvas.width = 1; 789 + const gradCtx = gradCanvas.getContext("2d"); 790 + const peakCanvas = document.createElement("canvas"); 791 + peakCanvas.width = 1; peakCanvas.height = 1; 792 + const peakCtx = peakCanvas.getContext("2d"); 793 + if (peakCtx) { 794 + peakCtx.fillStyle = "rgb(150,150,150)"; 795 + peakCtx.fillRect(0, 0, 1, 1); 796 + } 797 + 798 + let cachedH = 0; 799 + let cachedW = 0; 800 + 801 + /** @param {number} W @param {number} H */ 802 + const rebuildCaches = (W, H) => { 803 + bgCanvas.width = W; bgCanvas.height = H; 804 + if (bgCtx) { 805 + bgCtx.fillStyle = "rgb(0,0,0)"; 806 + bgCtx.fillRect(0, 0, W, H); 807 + bgCtx.fillStyle = "rgb(24,33,41)"; 808 + for (let x = 0; x < W; x += 2) 809 + for (let y = 1; y < H; y += 2) 810 + bgCtx.fillRect(x, y, 1, 1); 811 + } 812 + gradCanvas.height = H; 813 + if (gradCtx) { 814 + const maxColorIdx = WinampElement.#BAR_COLORS.length - 1; 815 + for (let y = 0; y < H; y++) { 816 + const colorIdx = H > 1 ? Math.round((y / (H - 1)) * maxColorIdx) : 0; 817 + gradCtx.fillStyle = WinampElement.#BAR_COLORS[colorIdx] ?? "rgb(0,0,0)"; 818 + gradCtx.fillRect(0, y, 1, 1); 819 + } 820 + } 821 + cachedH = H; cachedW = W; 822 + }; 823 + 824 + const logMaxFreqIndex = Math.log10(512); 825 + const scale = 0.91; 826 + 827 + const step = () => { 828 + this.#visRAF = requestAnimationFrame(step); 829 + 830 + const W = canvas.width; 831 + const H = canvas.height; 832 + if (W !== cachedW || H !== cachedH) rebuildCaches(W, H); 833 + 834 + // Height-dependent constants 835 + const FULL_HEIGHT = 15; // reference height (16px canvas) 836 + const MAX_HEIGHT = H - 1; 837 + const HEIGHT_SCALE = MAX_HEIGHT / FULL_HEIGHT; 838 + const PUSH_DOWN = H >= 16 ? 2 : 0; 839 + 840 + // Lazy-connect current audio 841 + const audioId = this.$queue.value?.now()?.id; 842 + if (audioId) this.#connectToAnalyser(audioId); 843 + 844 + // Time-domain → FFT → spectral data 845 + analyser.getByteTimeDomainData(timeDomainBuf); 846 + for (let i = 0; i < 1024; i++) inWaveData[i] = (timeDomainBuf[i] - 128) / 24; 847 + fft.timeToFrequencyDomain(inWaveData, outSpectralData); 848 + 849 + for (let x = 0; x < MAX_WIDTH; x++) { 850 + const linIdx = (x / (MAX_WIDTH - 1)) * 511; 851 + const logIdx = Math.pow(10, (logMaxFreqIndex * x) / (MAX_WIDTH - 1)); 852 + const si = (1.0 - scale) * linIdx + scale * logIdx; 853 + const i1 = Math.min(511, Math.floor(si)); 854 + const i2 = Math.min(511, Math.ceil(si)); 855 + sample[x] = i1 === i2 856 + ? outSpectralData[i1] 857 + : (1 - (si - i1)) * outSpectralData[i1] + (si - i1) * outSpectralData[i2]; 858 + } 859 + 860 + ctx.drawImage(bgCanvas, 0, 0); 861 + 862 + for (let x = 0; x < MAX_WIDTH; x++) { 863 + const chunk = x & ~3; 864 + const saData = Math.min( 865 + (((sample[chunk] ?? 0) + (sample[chunk + 1] ?? 0) + 866 + (sample[chunk + 2] ?? 0) + (sample[chunk + 3] ?? 0)) / 4) * HEIGHT_SCALE, 867 + MAX_HEIGHT 868 + ); 869 + 870 + if (saPeaks[x] >= MAX_HEIGHT * 256) saPeaks[x] = MAX_HEIGHT * 256; 871 + 872 + saFalloff[x] -= FALL_RATE; 873 + if (saFalloff[x] < saData) saFalloff[x] = saData; 874 + 875 + if (saPeaks[x] <= Math.round(saFalloff[x] * 256)) { 876 + saPeaks[x] = saFalloff[x] * 256; 877 + saData2[x] = 3.0; 878 + } 879 + barPeak[x] = saPeaks[x] / 256; 880 + saPeaks[x] -= Math.round(saData2[x]); 881 + saData2[x] *= PEAK_FALLOFF; 882 + if (saPeaks[x] < 0) saPeaks[x] = 0; 883 + if (Math.round(barPeak[x]) < 1) barPeak[x] = -3; 884 + 885 + if (x === chunk + 3) continue; 886 + 887 + const barHeight = Math.round(saFalloff[x]) - PUSH_DOWN; 888 + if (barHeight > 0) { 889 + ctx.drawImage(gradCanvas, 0, H - barHeight, 1, barHeight, x, H - barHeight, 1, barHeight); 890 + } 891 + 892 + const peakHeight = barPeak[x] + 1 - PUSH_DOWN; 893 + if (peakHeight > 0) { 894 + ctx.drawImage(peakCanvas, 0, 0, 1, 1, x, H - peakHeight, 1, 1); 895 + } 896 + } 897 + }; 898 + 899 + step(); 900 + } 901 + 902 + // EQ 903 + 904 + #applyEq() { 905 + if (!this.#preampNode || !this.#eqNodes.length) return; 906 + const { preamp, bands } = this.#eqSliders.value; 907 + const on = this.#eqOn.value; 908 + const preampDB = on ? (preamp - 50) / 50 * 12 : 0; 909 + this.#preampNode.gain.value = Math.pow(10, preampDB / 20); 910 + this.#eqNodes.forEach((node, i) => { 911 + node.gain.value = on ? (bands[i] - 50) / 50 * 12 : 0; 912 + }); 913 + } 914 + 915 + #saveEqState() { 916 + const { preamp, bands } = this.#eqSliders.value; 917 + /** @type {Record<string, number>} */ 918 + const sliders = { preamp }; 919 + bands.forEach((v, i) => { sliders[`band_${i}`] = v; }); 920 + const ui = loadUiState(); 921 + localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, eqOn: this.#eqOn.value, eqSliders: sliders })); 922 + } 923 + 924 + /** 925 + * @param {MouseEvent} e 926 + * @param {boolean} isPreamp 927 + * @param {number} bandIndex 928 + */ 929 + #startSliderDrag(e, isPreamp, bandIndex = 0) { 930 + e.preventDefault(); 931 + const RANGE = 52; // px travel for handle (63 - 11) 932 + const SNAP = 5; // webamp BAND_SNAP_DISTANCE 933 + const startY = e.clientY; 934 + const startValue = isPreamp ? this.#eqSliders.value.preamp : this.#eqSliders.value.bands[bandIndex]; 935 + const startTop = Math.floor((1 - startValue / 100) * RANGE); 936 + 937 + const onMove = (/** @type {MouseEvent} */ mv) => { 938 + const newTop = Math.max(0, Math.min(RANGE, startTop + mv.clientY - startY)); 939 + const raw = Math.round((1 - newTop / RANGE) * 100); 940 + const value = Math.abs(raw - 50) < SNAP ? 50 : raw; 941 + const cur = this.#eqSliders.value; 942 + if (isPreamp) { 943 + this.#eqSliders.value = { ...cur, preamp: value }; 944 + } else { 945 + const bands = [...cur.bands]; 946 + bands[bandIndex] = value; 947 + this.#eqSliders.value = { ...cur, bands }; 948 + } 949 + this.#applyEq(); 950 + this.#drawEqGraph(); 951 + }; 952 + 953 + const onUp = () => { 954 + document.removeEventListener("mousemove", onMove); 955 + document.removeEventListener("mouseup", onUp); 956 + this.#saveEqState(); 957 + }; 958 + document.addEventListener("mousemove", onMove); 959 + document.addEventListener("mouseup", onUp); 960 + } 961 + 962 + #toggleEqOn = () => { 963 + this.#eqOn.value = !this.#eqOn.value; 964 + this.#applyEq(); 965 + this.#saveEqState(); 966 + requestAnimationFrame(() => this.#drawEqGraph()); 967 + }; 968 + 969 + #resetEq = () => { 970 + this.#eqSliders.value = { preamp: 50, bands: Array(10).fill(50) }; 971 + this.#applyEq(); 972 + this.#saveEqState(); 973 + requestAnimationFrame(() => this.#drawEqGraph()); 974 + }; 975 + 214 976 // EQ GRAPH 215 977 216 978 // EQ_GRAPH_LINE_COLORS: 1×19px gradient from webamp's default skin ··· 242 1004 const ctx = canvas.getContext("2d"); 243 1005 if (!ctx) return; 244 1006 245 - // All bands at center (50 = 0dB): y = round(0.5 * (19-1)) = 9 246 - const y = 9; 1007 + const { bands } = this.#eqSliders.value; 1008 + const H = canvas.height - 1; // 18 247 1009 const paddingLeft = 2; 248 - const maxX = 108; // 9 intervals × 12px for 10 bands 1010 + const totalW = 108; // 9 intervals × 12px 1011 + 1012 + ctx.clearRect(0, 0, canvas.width, canvas.height); 249 1013 250 - for (let x = 0; x <= maxX; x++) { 251 - const [r, g, b] = WinampElement.#EQ_COLORS[y]; 1014 + for (let x = 0; x <= totalW; x++) { 1015 + // Interpolate between the 10 band values 1016 + const t = (x / totalW) * 9; 1017 + const i1 = Math.min(9, Math.floor(t)); 1018 + const i2 = Math.min(9, i1 + 1); 1019 + const v = bands[i1] * (1 - (t - i1)) + bands[i2] * (t - i1); 1020 + const y = Math.round((1 - v / 100) * H); 1021 + const [r, g, b] = WinampElement.#EQ_COLORS[y] ?? WinampElement.#EQ_COLORS[0]; 252 1022 ctx.fillStyle = `rgb(${r},${g},${b})`; 253 1023 ctx.fillRect(paddingLeft + x, y, 1, 1); 254 1024 } ··· 270 1040 }; 271 1041 272 1042 /** @param {Event} e */ 1043 + #onBalanceInput = (e) => { 1044 + if (!(e.target instanceof HTMLInputElement)) return; 1045 + this.#balance.value = Number(e.target.value); 1046 + }; 1047 + 1048 + /** @param {Event} e */ 273 1049 #onPositionInput = (e) => { 274 1050 if (!(e.target instanceof HTMLInputElement)) return; 275 1051 const percentage = Number(e.target.value) / 100; 1052 + this.#seekingProgress.value = percentage; 1053 + const duration = this.audio()?.duration() ?? 0; 1054 + const secs = Math.round(percentage * duration); 1055 + const m = Math.floor(secs / 60); 1056 + const s = secs % 60; 1057 + this.#marqueeOverride.value = `Seek to: ${m}:${String(s).padStart(2, "0")} (${Math.round(percentage * 100)}%)`; 1058 + clearTimeout(this.#marqueeOverrideTimeout); 1059 + }; 1060 + 1061 + /** @param {Event} e */ 1062 + #onPositionChange = (e) => { 1063 + if (!(e.target instanceof HTMLInputElement)) return; 1064 + const percentage = Number(e.target.value) / 100; 276 1065 const audioId = this.$queue.value?.now()?.id; 277 1066 if (audioId) this.$audio.value?.seek({ audioId, percentage }); 1067 + this.#marqueeOverride.value = null; 1068 + setTimeout(() => { this.#seekingProgress.value = null; }, 250); 278 1069 }; 279 1070 280 1071 #playPause = () => { 281 1072 const audioId = this.$queue.value?.now()?.id; 1073 + this.#stopped.value = false; 282 1074 if (this.isPlaying() && audioId) { 283 1075 this.$audio.value?.pause({ audioId }); 284 1076 } else if (audioId) { ··· 286 1078 } 287 1079 }; 288 1080 1081 + #stop = () => { 1082 + const audioId = this.$queue.value?.now()?.id; 1083 + if (!audioId) return; 1084 + if (this.isPlaying()) this.$audio.value?.pause({ audioId }); 1085 + this.$audio.value?.seek({ audioId, percentage: 0 }); 1086 + this.#stopped.value = true; 1087 + }; 1088 + 1089 + #openConnect = () => { 1090 + window.open("l/?path", "_blank"); 1091 + }; 1092 + 289 1093 #next = () => { 290 1094 this.$queue.value?.shift(); 291 1095 }; ··· 304 1108 if (rs) rs.setRepeat(!rs.repeat()); 305 1109 }; 306 1110 1111 + #closeMain = () => { 1112 + this.#mainOpen.value = false; 1113 + }; 1114 + 1115 + open = () => { 1116 + this.#mainOpen.value = true; 1117 + }; 1118 + 307 1119 #toggleEq = () => { 308 1120 this.#eqOpen.value = !this.#eqOpen.value; 309 1121 const ui = loadUiState(); ··· 313 1125 ); 314 1126 }; 315 1127 1128 + #toggleMainShade = () => { 1129 + this.#mainShade.value = !this.#mainShade.value; 1130 + const ui = loadUiState(); 1131 + localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, mainShade: this.#mainShade.value })); 1132 + }; 1133 + 1134 + #toggleEqShade = () => { 1135 + this.#eqShade.value = !this.#eqShade.value; 1136 + const ui = loadUiState(); 1137 + localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, eqShade: this.#eqShade.value })); 1138 + if (!this.#eqShade.value) { 1139 + requestAnimationFrame(() => this.#drawEqGraph()); 1140 + } 1141 + }; 1142 + 1143 + #togglePlaylistShade = () => { 1144 + this.#playlistShade.value = !this.#playlistShade.value; 1145 + const ui = loadUiState(); 1146 + localStorage.setItem(UI_STATE_KEY, JSON.stringify({ ...ui, playlistShade: this.#playlistShade.value })); 1147 + }; 1148 + 316 1149 #togglePlaylist = () => { 317 1150 this.#playlistOpen.value = !this.#playlistOpen.value; 318 1151 const ui = loadUiState(); ··· 355 1188 * @param {RenderArg} _ 356 1189 */ 357 1190 render({ html }) { 358 - const bands = EQ_BANDS; 1191 + requestAnimationFrame(() => this.#updatePlaylistHandle()); 1192 + 1193 + const { preamp: preampVal, bands: bandVals } = this.#eqSliders.value; 1194 + 1195 + /** 1196 + * @param {number} value 0–100 1197 + * @param {boolean} isPreamp 1198 + * @param {number} bandIndex 1199 + */ 1200 + const bandSlider = (value, isPreamp, bandIndex = 0) => { 1201 + const handleTop = Math.floor((1 - value / 100) * 52); 1202 + const n = Math.round((value / 100) * 27); 1203 + const sx = (n % 14) * 15; 1204 + const sy = Math.floor(n / 14) * 65; 1205 + return html` 1206 + <div class="band" style="background-position: -${sx}px -${sy}px; width: 14px; height: 63px; position: relative;"> 1207 + <div 1208 + class="slider-handle" 1209 + style="position: absolute; top: ${handleTop}px; width: 11px; height: 11px; margin-left: 1px;" 1210 + @mousedown="${(/** @type {MouseEvent} */ e) => this.#startSliderDrag(e, isPreamp, bandIndex)}" 1211 + ></div> 1212 + </div> 1213 + `; 1214 + }; 359 1215 360 1216 const volume = this.$audio.value?.volume() ?? 1; 361 1217 const volumeSprite = Math.round(volume * 28); 362 1218 const volumeBgPos = `0 -${(volumeSprite - 1) * 15}px`; 1219 + const volumePct = Math.round(volume * 100); 1220 + const volumeClass = volumePct < 50 ? "left" : volumePct > 50 ? "right" : "center"; 1221 + const balance = this.#balance.value; 1222 + const balanceClass = balance < 0 ? "left" : balance > 0 ? "right" : "center"; 363 1223 364 1224 const audio = this.audio(); 1225 + const focused = this.#focusedWindow.value; 365 1226 366 - const timeSeconds = audio?.currentTime() ?? 0; 1227 + // Track metadata 1228 + const track = this.currentTrack(); 1229 + const kbps = track?.stats?.bitrate ? Math.round(track.stats.bitrate / 1000) : null; 1230 + const khz = track?.stats?.sampleRate ? Math.round(track.stats.sampleRate / 1000) : null; 1231 + const channels = track?.stats?.numberOfChannels ?? null; 1232 + const isStereo = channels !== null && channels >= 2; 1233 + const isMono = channels === 1; 1234 + const kbpsChars = kbps != null ? [...String(kbps)].map((c) => 1235 + html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1236 + ) : []; 1237 + const khzChars = khz != null ? [...String(khz)].map((c) => 1238 + html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1239 + ) : []; 1240 + 1241 + const seekPct = this.#seekingProgress.value; 1242 + const timeSeconds = seekPct !== null 1243 + ? seekPct * (audio?.duration() ?? 0) 1244 + : audio?.currentTime() ?? 0; 367 1245 const timeMinutes = Math.floor(timeSeconds / 60); 368 1246 const timeSecs = Math.floor(timeSeconds % 60); 1247 + const miniTimeStr = `${String(timeMinutes).padStart(2, "0")}:${String(timeSecs).padStart(2, "0")}`; 1248 + const miniTimeChars = [...miniTimeStr].map((c, i) => 1249 + c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1250 + ); 369 1251 const d = { 370 1252 mFirst: Math.floor(timeMinutes / 10), 371 1253 mSecond: timeMinutes % 10, ··· 395 1277 const label = artist ? `${artist} - ${title}` : title; 396 1278 const durSec = track?.stats?.duration ? track.stats.duration / 1000 : 0; 397 1279 const dur = durSec > 0 398 - ? `${Math.floor(durSec / 60)}:${String(Math.floor(durSec % 60)).padStart(2, "0")}` 1280 + ? `${Math.floor(durSec / 60)}:${ 1281 + String(Math.floor(durSec % 60)).padStart(2, "0") 1282 + }` 399 1283 : ""; 400 1284 const color = isCurrent ? "#FFFFFF" : "#00FF00"; 401 1285 const bg = isSelected && !isCurrent ? "#0000FF" : "transparent"; 402 1286 return { id: item.id, n: i + 1, label, dur, color, bg }; 403 1287 }); 404 1288 1289 + // Playlist running time display: currentTrackDuration/totalPlaylistDuration 1290 + const nowTrackSec = (() => { 1291 + const d = trackMap.get(nowItem?.id ?? "")?.stats?.duration; 1292 + return d ? d / 1000 : 0; 1293 + })(); 1294 + const totalSec = allItems.reduce((sum, item) => { 1295 + const d = trackMap.get(item.id)?.stats?.duration; 1296 + return sum + (d ? d / 1000 : 0); 1297 + }, 0); 1298 + const fmtDur = (s) => { 1299 + const h = Math.floor(s / 3600); 1300 + const m = Math.floor((s % 3600) / 60); 1301 + const sec = Math.floor(s % 60); 1302 + return h > 0 1303 + ? `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}` 1304 + : `${m}:${String(sec).padStart(2, "0")}`; 1305 + }; 1306 + const runningTimeStr = `${fmtDur(nowTrackSec)}/${fmtDur(totalSec)}`; 1307 + const totalTimeChars = [...runningTimeStr].map((c) => 1308 + html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1309 + ); 1310 + 1311 + const isPaused = !!audio && !this.isPlaying() && !this.#stopped.value; 1312 + 1313 + // Playlist mini-time (current playback position) 1314 + const playlistMiniTimeChars = [...miniTimeStr].map((c, i) => 1315 + c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1316 + ); 1317 + 1318 + // Playlist shade: current track title + time 1319 + const nowTrack = trackMap.get(nowItem?.id ?? ""); 1320 + const shadeArtist = nowTrack?.tags?.artist ?? ""; 1321 + const shadeTitle = shadeArtist 1322 + ? `${shadeArtist} - ${nowTrack?.tags?.title ?? ""}`.toLowerCase() 1323 + : (nowTrack?.tags?.title ?? "").toLowerCase(); 1324 + const shadeTitleChars = [...shadeTitle].map((c) => 1325 + html`<span class="character character-${c.charCodeAt(0)}">${c}</span>` 1326 + ); 1327 + const shadeTimeChars = [...miniTimeStr].map((c, i) => 1328 + c === ":" ? null : html`<span class="character character-${c.charCodeAt(0)}" style="left: ${i * 5}px">${c}</span>` 1329 + ); 1330 + 405 1331 const activeMarquee = this.#marqueeOverride.value ?? 406 1332 this.#marqueeText.value; 407 1333 const loopedMarquee = WinampElement.#marqueeLoopText(activeMarquee); ··· 414 1340 ` 415 1341 ); 416 1342 417 - const band = html` 418 - <div 419 - class="band" 420 - style="background-position: 0px -65px; width: 14px; height: 63px; position: relative;" 421 - > 422 - <div 423 - class="slider-handle" 424 - style="position: absolute; top: 26px; width: 11px; height: 11px; margin-left: 1px;" 425 - > 426 - </div> 427 - </div> 428 - `; 429 - 430 1343 return html` 431 1344 <style> 432 1345 @import "./themes/winamp/vendor/webamp.css"; 1346 + 1347 + #webamp .playlist-track-titles > div { 1348 + padding-left: 3px; 1349 + } 1350 + 1351 + #webamp .playlist-middle-center { 1352 + scrollbar-width: none; 1353 + } 1354 + #webamp .playlist-middle-center::-webkit-scrollbar { 1355 + display: none; 1356 + } 1357 + #webamp .playlist-middle { 1358 + min-height: 0; 1359 + } 1360 + #webamp .playlist-scrollbar { 1361 + position: absolute; 1362 + top: 0; 1363 + bottom: 0; 1364 + right: 7px; 1365 + width: 8px; 1366 + } 1367 + #webamp .playlist-scrollbar-handle { 1368 + position: absolute; 1369 + top: 0; 1370 + width: 8px; 1371 + height: 18px; 1372 + } 1373 + #webamp #main-window.shade, 1374 + #webamp #equalizer-window.shade { 1375 + overflow: hidden; 1376 + } 1377 + #webamp #main-window.shade #visualizer { 1378 + clip-path: inset(0 38px 0 0); 1379 + } 1380 + #webamp #playlist-window .mini-time { 1381 + left: 72px; 1382 + right: auto; 1383 + } 433 1384 </style> 434 1385 435 1386 <div id="webamp"> 436 1387 <div 437 1388 id="main-window" 438 - class="window ${this.isPlaying() 439 - ? "play" 440 - : audio 441 - ? "pause" 442 - : "stop"} draggable" 443 - style="position: absolute; top: 0; left: 0;" 1389 + class="window ${this.#stopped.value ? "stop" : this.isPlaying() ? "play" : audio ? "pause" : "stop"}${this.#mainShade.value ? " shade" : ""}" 1390 + style="position: absolute; top: ${this.#mainPos.y}px; left: ${this.#mainPos.x}px; display: ${this.#mainOpen.value ? "block" : "none"};" 444 1391 > 445 - <div id="title-bar" class="selected draggable"> 1392 + <div id="title-bar" class="${focused === "main" ? "selected " : ""}draggable" @dblclick="${this.#toggleMainShade}"> 446 1393 <div id="option-context"><div id="option"></div></div> 447 1394 <div id="minimize"></div> 448 - <div id="shade"></div> 449 - <div id="close"></div> 1395 + <div id="shade" @click="${this.#toggleMainShade}"></div> 1396 + ${this.#mainShade.value ? html`<div class="mini-time${isPaused ? " blinking" : ""}">${miniTimeChars}</div>` : ""} 1397 + <div id="close" @click="${this.#closeMain}"></div> 450 1398 </div> 451 1399 <div class="webamp-status"> 452 1400 <div id="clutter-bar"> ··· 470 1418 .sSecond}"></div> 471 1419 </div> 472 1420 </div> 473 - <canvas id="visualizer" width="76" height="16"></canvas> 1421 + <canvas id="visualizer" width="76" height="${this.#mainShade.value ? 5 : 16}"></canvas> 474 1422 <div class="media-info"> 475 1423 <div id="marquee"> 476 1424 <div style="white-space: nowrap; will-change: transform; font-size: 0;"> 477 1425 ${marqueeChars} 478 1426 </div> 479 1427 </div> 480 - <div id="kbps"></div> 481 - <div id="khz"></div> 1428 + <div id="kbps">${kbpsChars}</div> 1429 + <div id="khz">${khzChars}</div> 482 1430 <div class="mono-stereo"> 483 - <div id="mono"></div> 484 - <div id="stereo"></div> 1431 + <div id="mono" class="${isMono ? "selected" : ""}"></div> 1432 + <div id="stereo" class="${isStereo ? "selected" : ""}"></div> 485 1433 </div> 486 1434 </div> 487 1435 <div id="volume" style="background-position: ${volumeBgPos};"> ··· 507 1455 id="position" 508 1456 min="0" 509 1457 max="100" 510 - .value="${(this 511 - .audio()?.loadingState() === "loaded" 1458 + .value="${(this.#seekingProgress.value !== null 1459 + ? this.#seekingProgress.value 1460 + : this.audio()?.loadingState() === "loaded" 512 1461 ? (this.audio()?.progress() ?? 0) 513 1462 : 0) * 100}" 514 1463 @input="${this.#onPositionInput}" 1464 + @change="${this.#onPositionChange}" 515 1465 > 516 1466 <div class="actions"> 517 1467 <div id="previous" @click="${this.#previous}"></div> 518 1468 <div id="play" @click="${this.#playPause}"></div> 519 1469 <div id="pause" @click="${this.#playPause}"></div> 520 - <div id="stop"></div> 1470 + <div id="stop" @click="${this.#stop}"></div> 521 1471 <div id="next" @click="${this.#next}"></div> 522 1472 </div> 523 - <div id="eject"></div> 1473 + <div id="eject" @click="${this.#openConnect}"></div> 524 1474 <div class="shuffle-repeat"> 525 1475 <div id="shuffle" class="${this.$repeatShuffle.value?.shuffle() 526 1476 ? "selected" ··· 534 1484 535 1485 <div 536 1486 id="equalizer-window" 537 - class="window draggable" 538 - style="position: absolute; top: 116px; left: 0; display: ${this 539 - .#eqOpen.value 540 - ? "block" 541 - : "none"};" 1487 + class="window${this.#eqShade.value ? " shade" : ""}" 1488 + style="position: absolute; top: ${this.#eqPos.y}px; left: ${this.#eqPos.x}px; display: ${this.#eqOpen.value ? "block" : "none"};" 1489 + > 1490 + ${this.#eqShade.value ? html` 1491 + <div class="draggable" style="width: 100%; height: 100%;" @dblclick="${this.#toggleEqShade}"> 1492 + <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 1493 + <div id="equalizer-close" @click="${this.#toggleEq}"></div> 1494 + <input type="range" id="equalizer-volume" class="${volumeClass}" min="0" max="100" value="${volumePct}" @input="${this.#onVolumeInput}"> 1495 + <input type="range" id="equalizer-balance" class="${balanceClass}" min="-100" max="100" value="${balance}" @input="${this.#onBalanceInput}"> 1496 + </div> 1497 + ` : html` 1498 + <div class="equalizer-top title-bar${focused === "eq" ? " selected" : ""} draggable" @dblclick="${this.#toggleEqShade}"> 1499 + <div id="equalizer-shade" @click="${this.#toggleEqShade}"></div> 1500 + <div id="equalizer-close" @click="${this.#toggleEq}"></div> 1501 + </div> 1502 + <div id="on" class="${this.#eqOn.value ? "selected" : ""}" @click="${this.#toggleEqOn}"></div> 1503 + <div id="auto" @click="${this.#resetEq}"></div> 1504 + <canvas id="eqGraph" width="113" height="19"></canvas> 1505 + <div id="presets-context"><div id="presets"></div></div> 1506 + <div id="preamp">${bandSlider(preampVal, true)}</div> 1507 + <div id="preamp-line"></div> 1508 + <div id="plus12db"></div> 1509 + <div id="zerodb"></div> 1510 + <div id="minus12db"></div> 1511 + ${EQ_BANDS.map((hz, i) => 1512 + html`<div id="band-${hz}">${bandSlider(bandVals[i], false, i)}</div>` 1513 + )} 1514 + `} 1515 + </div> 1516 + 1517 + ${this.#playlistShade.value && this.#playlistOpen.value ? html` 1518 + <div 1519 + id="playlist-window-shade" 1520 + class="window draggable${focused === "playlist" ? " selected" : ""}" 1521 + style="position: absolute; top: ${this.#playlistPos.y}px; left: ${this.#playlistPos.x}px; width: ${this.#playlistSize.width}px;" 542 1522 > 543 - <div class="equalizer-top title-bar draggable"> 544 - <div id="equalizer-shade"></div> 545 - <div id="equalizer-close"></div> 1523 + <div class="left"> 1524 + <div class="right draggable"> 1525 + <div id="playlist-shade-track-title">${shadeTitleChars}</div> 1526 + <div id="playlist-shade-time">${shadeTimeChars}</div> 1527 + <div id="playlist-shade-button" @click="${this.#togglePlaylistShade}"></div> 1528 + <div id="playlist-close-button" @click="${this.#togglePlaylist}"></div> 1529 + </div> 546 1530 </div> 547 - <input type="range" id="equalizer-volume" min="0" max="100" value="100"> 548 - <input type="range" id="equalizer-balance" min="-100" max="100" value="0"> 549 - <div id="on"></div> 550 - <div id="auto"></div> 551 - <canvas id="eqGraph" width="113" height="19"></canvas> 552 - <div id="presets-context"><div id="presets"></div></div> 553 - <div id="preamp">${band}</div> 554 - <div id="preamp-line"></div> 555 - <div id="plus12db"></div> 556 - <div id="zerodb"></div> 557 - <div id="minus12db"></div> 558 - ${bands.map((hz) => 559 - html` 560 - <div id="band-${hz}">${band}</div> 561 - ` 562 - )} 563 1531 </div> 1532 + ` : ""} 564 1533 565 1534 <div 566 1535 id="playlist-window" 567 - class="window draggable" 568 - style="position: absolute; top: 232px; left: 0; height: 116px; width: 275px; display: ${this 569 - .#playlistOpen.value 570 - ? "block" 571 - : "none"};" 1536 + class="window${focused === "playlist" ? " selected" : ""}" 1537 + 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"};" 572 1538 > 573 - <div class="playlist-top draggable"> 1539 + <div class="playlist-top draggable" @dblclick="${this.#togglePlaylistShade}"> 574 1540 <div class="playlist-top-left draggable"></div> 575 1541 <div class="playlist-top-left-fill draggable"></div> 576 1542 <div class="playlist-top-title draggable"></div> 577 1543 <div class="playlist-top-right-fill draggable"></div> 578 1544 <div class="playlist-top-right draggable"> 579 - <div id="playlist-shade-button"></div> 580 - <div id="playlist-close-button"></div> 1545 + <div id="playlist-shade-button" @click="${this.#togglePlaylistShade}"></div> 1546 + <div id="playlist-close-button" @click="${this.#togglePlaylist}"></div> 581 1547 </div> 582 1548 </div> 583 - <div class="playlist-middle draggable"> 584 - <div class="playlist-middle-left draggable"></div> 585 - <div class="playlist-middle-center" style="background-color: #000000; overflow-y: auto;"> 1549 + <div class="playlist-middle"> 1550 + <div class="playlist-middle-left"></div> 1551 + <div 1552 + class="playlist-middle-center" 1553 + style="background-color: #000000; overflow-y: auto; font-family: Arial, sans-serif;" 1554 + > 586 1555 <div class="playlist-tracks"> 587 1556 <div class="playlist-track-titles"> 588 1557 ${playlistRows.map((r) => 589 - html`<div class="track-cell" style="color: ${r.color}; background-color: ${r.bg};" @click="${() => this.#selectTrack(r.id)}" @dblclick="${() => this.#playTrack(r.id)}">${r.n}. ${r.label}</div>` 1558 + html` 1559 + <div 1560 + class="track-cell" 1561 + style="color: ${r.color}; background-color: ${r.bg};" 1562 + @click="${() => this.#selectTrack(r.id)}" 1563 + @dblclick="${() => this.#playTrack(r.id)}" 1564 + > 1565 + ${r.n}. ${r.label} 1566 + </div> 1567 + ` 590 1568 )} 591 1569 </div> 592 1570 <div class="playlist-track-durations"> 593 1571 ${playlistRows.map((r) => 594 - html`<div class="track-cell" style="color: ${r.color}; background-color: ${r.bg};" @click="${() => this.#selectTrack(r.id)}" @dblclick="${() => this.#playTrack(r.id)}">${r.dur}</div>` 1572 + html` 1573 + <div 1574 + class="track-cell" 1575 + style="color: ${r.color}; background-color: ${r.bg};" 1576 + @click="${() => this.#selectTrack(r.id)}" 1577 + @dblclick="${() => this.#playTrack(r.id)}" 1578 + > 1579 + ${r.dur} 1580 + </div> 1581 + ` 595 1582 )} 596 1583 </div> 597 1584 </div> 598 1585 </div> 599 - <div class="playlist-middle-right draggable"> 600 - <div id="playlist-scroll-up-button"></div> 601 - <div id="playlist-scroll-down-button"></div> 1586 + <div class="playlist-middle-right"> 1587 + <div class="playlist-scrollbar"> 1588 + <div class="playlist-scrollbar-handle"></div> 1589 + </div> 602 1590 </div> 603 1591 </div> 604 - <div class="playlist-bottom draggable"> 605 - <div class="playlist-bottom-left draggable"> 606 - <div id="playlist-add-menu" class="playlist-menu"></div> 1592 + <div class="playlist-bottom"> 1593 + <div class="playlist-bottom-left"> 1594 + <div id="playlist-add-menu" class="playlist-menu" @click="${this.#openConnect}"></div> 607 1595 <div id="playlist-remove-menu" class="playlist-menu"></div> 608 1596 <div id="playlist-selection-menu" class="playlist-menu"></div> 609 1597 <div id="playlist-misc-menu" class="playlist-menu"></div> 610 1598 </div> 611 - <div class="playlist-bottom-center draggable"></div> 612 - <div class="playlist-bottom-right draggable"> 1599 + <div class="playlist-bottom-center"></div> 1600 + <div class="playlist-bottom-right"> 1601 + <div class="playlist-running-time-display draggable">${totalTimeChars}</div> 1602 + <div class="mini-time${isPaused ? " blinking" : ""}">${playlistMiniTimeChars}</div> 1603 + <div class="playlist-action-buttons"> 1604 + <div class="playlist-previous-button" @click="${this.#previous}"></div> 1605 + <div class="playlist-play-button" @click="${this.#playPause}"></div> 1606 + <div class="playlist-pause-button" @click="${this.#playPause}"></div> 1607 + <div class="playlist-stop-button" @click="${this.#stop}"></div> 1608 + <div class="playlist-next-button" @click="${this.#next}"></div> 1609 + <div class="playlist-eject-button" @click="${this.#openConnect}"></div> 1610 + </div> 613 1611 <div id="playlist-list-menu" class="playlist-menu"></div> 614 1612 <div id="playlist-resize-target"></div> 615 1613 </div>