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: various winamp theme improvements

+382 -104
+1
src/components/engine/queue/element.js
··· 26 26 27 27 this.add = this.proxy.add; 28 28 this.clear = this.proxy.clear; 29 + this.expel = this.proxy.expel; 29 30 this.fill = this.proxy.fill; 30 31 this.move = this.proxy.move; 31 32 this.shift = this.proxy.shift;
+3 -1
src/components/engine/queue/types.d.ts
··· 6 6 * Clear the `future()` items. 7 7 */ 8 8 clear: (args: { keepManual?: boolean }) => void; 9 + expel: (args: { key: string }) => void; 9 10 fill: ( 10 11 args: { 11 12 /** Always keep adding, even if the amount of non-manual items in the queue are passed the given `amount` */ ··· 14 15 shuffled: boolean; 15 16 }, 16 17 ) => void; 17 - move: (args: { from: number; to: number }) => void; 18 + move: (args: { key: string; to: number }) => void; 18 19 shift: () => void; 19 20 supply: (args: { trackIds: string[] }) => void; 20 21 unshift: () => void; ··· 22 23 23 24 export type Item = { 24 25 id: string; 26 + key: string; 25 27 manualEntry: boolean; 26 28 }; 27 29
+163 -48
src/components/engine/queue/worker.js
··· 11 11 // STATE 12 12 //////////////////////////////////////////// 13 13 14 + let _key = 0; 15 + const nextKey = () => String(++_key); 16 + 14 17 /** Ordered list of available track IDs. */ 15 18 export const $lake = signal(/** @type {string[]} */ ([])); 16 19 ··· 71 74 */ 72 75 export function add({ inFront, trackIds }) { 73 76 const items = trackIds.map((id) => { 74 - return { id, manualEntry: true }; 77 + return { id, key: nextKey(), manualEntry: true }; 75 78 }); 76 79 77 80 if (inFront) { ··· 126 129 } 127 130 128 131 /** 132 + * @type {Actions['expel']} 133 + * 134 + * @example Removes an item from the future by key 135 + * ```js 136 + * import { expel, $future } from "~/components/engine/queue/worker.js"; 137 + * 138 + * $future.value = [ 139 + * { id: "a", key: "1", manualEntry: true }, 140 + * { id: "b", key: "2", manualEntry: true }, 141 + * ]; 142 + * 143 + * expel({ key: "1" }); 144 + * 145 + * if ($future.value.length !== 1) throw new Error("expected 1 item remaining"); 146 + * if ($future.value[0].id !== "b") throw new Error("expected 'b' to remain"); 147 + * ``` 148 + * 149 + * @example Removes the now-playing item by key, setting now to null 150 + * ```js 151 + * import { expel, $now } from "~/components/engine/queue/worker.js"; 152 + * 153 + * $now.value = { id: "a", key: "1", manualEntry: false }; 154 + * 155 + * expel({ key: "1" }); 156 + * 157 + * if ($now.value !== null) throw new Error("expected now to be null"); 158 + * ``` 159 + * 160 + * @example Removes an item from the past by key 161 + * ```js 162 + * import { expel, $past } from "~/components/engine/queue/worker.js"; 163 + * 164 + * $past.value = [ 165 + * { id: "a", key: "1", manualEntry: false }, 166 + * { id: "b", key: "2", manualEntry: false }, 167 + * ]; 168 + * 169 + * expel({ key: "1" }); 170 + * 171 + * if ($past.value.length !== 1) throw new Error("expected 1 item remaining"); 172 + * if ($past.value[0].id !== "b") throw new Error("expected 'b' to remain"); 173 + * ``` 174 + * 175 + * @example Does nothing for an unknown key 176 + * ```js 177 + * import { expel, $past, $now, $future } from "~/components/engine/queue/worker.js"; 178 + * 179 + * $past.value = [{ id: "a", key: "1", manualEntry: false }]; 180 + * $now.value = { id: "b", key: "2", manualEntry: false }; 181 + * $future.value = [{ id: "c", key: "3", manualEntry: false }]; 182 + * 183 + * expel({ key: "z" }); 184 + * 185 + * if ($past.value.length !== 1) throw new Error("past should be unchanged"); 186 + * if ($now.value?.id !== "b") throw new Error("now should be unchanged"); 187 + * if ($future.value.length !== 1) throw new Error("future should be unchanged"); 188 + * ``` 189 + */ 190 + export function expel({ key }) { 191 + const pastIdx = $past.value.findIndex((i) => i.key === key); 192 + if (pastIdx !== -1) { 193 + const p = [...$past.value]; 194 + p.splice(pastIdx, 1); 195 + $past.value = p; 196 + return; 197 + } 198 + if ($now.value?.key === key) { 199 + $now.value = null; 200 + return; 201 + } 202 + const futureIdx = $future.value.findIndex((i) => i.key === key); 203 + if (futureIdx !== -1) { 204 + const f = [...$future.value]; 205 + f.splice(futureIdx, 1); 206 + $future.value = f; 207 + } 208 + } 209 + 210 + /** 129 211 * @type {Actions['fill']} 130 212 */ 131 213 export function fill({ augment, amount, shuffled }) { ··· 147 229 * import { move, $future } from "~/components/engine/queue/worker.js"; 148 230 * 149 231 * $future.value = [ 150 - * { id: "a", manualEntry: true }, 151 - * { id: "b", manualEntry: true }, 152 - * { id: "c", manualEntry: true }, 232 + * { id: "a", key: "1", manualEntry: true }, 233 + * { id: "b", key: "2", manualEntry: true }, 234 + * { id: "c", key: "3", manualEntry: true }, 153 235 * ]; 154 236 * 155 - * move({ from: 0, to: 2 }); 237 + * move({ key: "1", to: 2 }); 156 238 * 157 239 * if ($future.value[0].id !== "b") throw new Error("expected 'b' first"); 158 240 * if ($future.value[1].id !== "c") throw new Error("expected 'c' second"); ··· 164 246 * import { move, $future } from "~/components/engine/queue/worker.js"; 165 247 * 166 248 * $future.value = [ 167 - * { id: "a", manualEntry: true }, 168 - * { id: "b", manualEntry: true }, 169 - * { id: "c", manualEntry: true }, 249 + * { id: "a", key: "1", manualEntry: true }, 250 + * { id: "b", key: "2", manualEntry: true }, 251 + * { id: "c", key: "3", manualEntry: true }, 170 252 * ]; 171 253 * 172 - * move({ from: 2, to: 0 }); 254 + * move({ key: "3", to: 0 }); 173 255 * 174 256 * if ($future.value[0].id !== "c") throw new Error("expected 'c' first"); 175 257 * if ($future.value[1].id !== "a") throw new Error("expected 'a' second"); ··· 180 262 * ```js 181 263 * import { move, $past, $now, $future } from "~/components/engine/queue/worker.js"; 182 264 * 183 - * $past.value = [{ id: "a", manualEntry: false }]; 184 - * $now.value = { id: "b", manualEntry: false }; 185 - * $future.value = [{ id: "c", manualEntry: false }]; 265 + * $past.value = [{ id: "a", key: "1", manualEntry: false }]; 266 + * $now.value = { id: "b", key: "2", manualEntry: false }; 267 + * $future.value = [{ id: "c", key: "3", manualEntry: false }]; 186 268 * 187 269 * // flat list is [a(0), b(1), c(2)]; moving c to front → [c, a, b] 188 - * move({ from: 2, to: 0 }); 270 + * move({ key: "3", to: 0 }); 189 271 * 190 272 * if ($now.value?.id !== "b") throw new Error("now should still be 'b'"); 191 273 * if ($past.value[0]?.id !== "c") throw new Error("expected 'c' first in past"); ··· 193 275 * if ($future.value.length !== 0) throw new Error("future should be empty"); 194 276 * ``` 195 277 * 196 - * @example Does nothing when from equals to 278 + * @example Does nothing when the item is already at the target position 197 279 * ```js 198 280 * import { move, $future } from "~/components/engine/queue/worker.js"; 199 281 * 200 - * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; 282 + * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }]; 201 283 * 202 - * move({ from: 1, to: 1 }); 284 + * move({ key: "2", to: 1 }); 203 285 * 204 286 * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); 205 287 * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); 206 288 * ``` 207 289 * 208 - * @example Does nothing for out-of-bounds indices 290 + * @example Does nothing for out-of-bounds target or unknown key 209 291 * ```js 210 292 * import { move, $future } from "~/components/engine/queue/worker.js"; 211 293 * 212 - * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; 294 + * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }]; 213 295 * 214 - * move({ from: 0, to: 99 }); 296 + * move({ key: "1", to: 99 }); 297 + * move({ key: "z", to: 0 }); 215 298 * 216 299 * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); 217 300 * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); 218 301 * ``` 219 302 */ 220 - export function move({ from, to }) { 221 - const all = [ 222 - ...$past.value, 223 - ...($now.value ? [$now.value] : []), 224 - ...$future.value, 225 - ]; 303 + export function move({ key, to }) { 304 + const past = $past.value; 305 + const now = $now.value; 306 + const future = $future.value; 307 + const pLen = past.length; 308 + const nLen = now ? 1 : 0; 309 + const futureStart = pLen + nLen; 310 + const total = futureStart + future.length; 226 311 227 - if (from === to || from < 0 || to < 0 || from >= all.length || to >= all.length) return; 312 + let from = past.findIndex((i) => i.key === key); 313 + if (from === -1 && now?.key === key) from = pLen; 314 + if (from === -1) { 315 + const fi = future.findIndex((i) => i.key === key); 316 + if (fi !== -1) from = futureStart + fi; 317 + } 228 318 229 - const [item] = all.splice(from, 1); 230 - all.splice(to, 0, item); 319 + if (from === -1 || from === to || to < 0 || to >= total) return; 231 320 232 - const now = $now.value; 233 - if (now) { 234 - const nowIdx = all.indexOf(now); 235 - $past.value = all.slice(0, nowIdx); 236 - $now.value = all[nowIdx] ?? null; 237 - $future.value = all.slice(nowIdx + 1); 238 - } else { 239 - const pastLen = $past.value.length; 240 - $past.value = all.slice(0, pastLen); 241 - $future.value = all.slice(pastLen); 321 + // Compute now's new flat index after the move 322 + let nowIdx = pLen; 323 + if (nLen) { 324 + if (from === pLen) nowIdx = to; 325 + else if (from < to && pLen > from && pLen <= to) nowIdx = pLen - 1; 326 + else if (from > to && pLen >= to && pLen < from) nowIdx = pLen + 1; 242 327 } 328 + 329 + // Map a post-move flat index back to the original flat index 330 + const origIdx = (/** @type {number} */ i) => { 331 + if (from < to) { 332 + if (i < from) return i; 333 + if (i < to) return i + 1; 334 + if (i === to) return from; 335 + } else { 336 + if (i < to) return i; 337 + if (i === to) return from; 338 + if (i <= from) return i - 1; 339 + } 340 + return i; 341 + }; 342 + 343 + const flatGet = (/** @type {number} */ i) => { 344 + const j = origIdx(i); 345 + return j < pLen ? past[j] : j < futureStart ? now : future[j - futureStart]; 346 + }; 347 + 348 + $past.value = 349 + /** @type {Item[]} */ (Array.from( 350 + { length: nowIdx }, 351 + (_, i) => flatGet(i), 352 + )); 353 + $future.value = 354 + /** @type {Item[]} */ (Array.from( 355 + { length: total - nowIdx - nLen }, 356 + (_, i) => flatGet(nowIdx + nLen + i), 357 + )); 243 358 } 244 359 245 360 /** ··· 343 458 rpc(context, { 344 459 add, 345 460 clear, 461 + expel, 346 462 fill, 347 463 move, 348 464 shift, ··· 480 596 if (currIndex > maxIndex) currIndex = 0; 481 597 const id = $lake.value[currIndex]; 482 598 if (id) { 483 - autoItems.push({ id, manualEntry: false }); 599 + autoItems.push({ id, key: nextKey(), manualEntry: false }); 484 600 } 485 601 currIndex++; 486 602 } ··· 554 670 if ($now.value) excludeIds.add($now.value.id); 555 671 future.forEach((i) => excludeIds.add(i.id)); 556 672 557 - let pool = $lake.value 558 - .filter((id) => !excludeIds.has(id)) 559 - .map((id) => ({ id, manualEntry: false })); 673 + let pool = $lake.value.filter((id) => !excludeIds.has(id)); 560 674 561 675 // Fallback: if everything has been played/is playing/is queued, use tracks not in past or now 562 676 if (pool.length === 0) { 563 677 const pastAndNowIds = new Set($past.value.map((i) => i.id)); 564 678 if ($now.value) pastAndNowIds.add($now.value.id); 565 - pool = $lake.value 566 - .filter((id) => !pastAndNowIds.has(id)) 567 - .map((id) => ({ id, manualEntry: false })); 679 + pool = $lake.value.filter((id) => !pastAndNowIds.has(id)); 568 680 } 569 681 570 682 // Final fallback: everything has been played, use the full lake 571 683 if (pool.length === 0) { 572 - pool = $lake.value.map((id) => ({ id, manualEntry: false })); 684 + pool = [...$lake.value]; 573 685 } 574 686 575 - const poolSelection = arrayShuffle(pool).slice( 687 + const selected = arrayShuffle(pool).slice( 576 688 0, 577 689 Math.max(0, fillAmount - autoFutureCount), 578 690 ); 579 691 580 - return [...future, ...poolSelection]; 692 + return [ 693 + ...future, 694 + ...selected.map((id) => ({ id, key: nextKey(), manualEntry: false })), 695 + ]; 581 696 } 582 697 583 698 /**
+2 -2
src/facets/playback/queue/index.inline.js
··· 139 139 title="Move up" 140 140 ?disabled="${i === 0}" 141 141 @click="${() => 142 - queue.move({ from: offset + i, to: offset + i - 1 })}" 142 + queue.move({ key: item.key, to: offset + i - 1 })}" 143 143 > 144 144 <i class="ph-bold ph-arrow-up"></i> 145 145 </button> ··· 148 148 title="Move down" 149 149 ?disabled="${i === future.length - 1}" 150 150 @click="${() => 151 - queue.move({ from: offset + i, to: offset + i + 1 })}" 151 + queue.move({ key: item.key, to: offset + i + 1 })}" 152 152 > 153 153 <i class="ph-bold ph-arrow-down"></i> 154 154 </button>
+76 -24
src/facets/themes/winamp/browser/element.js
··· 52 52 ); 53 53 54 54 $highlightedTrack = signal(/** @type {string | null} */ (null)); 55 + $highlightedTracks = signal(/** @type {Set<string>} */ (new Set())); 56 + #anchorTrackId = /** @type {string | null} */ (null); 55 57 56 58 $input = signal( 57 59 /** @type {import("~/components/configurator/input/element.js").CLASS | undefined} */ (undefined), ··· 554 556 const totalHeight = totalTracks * ROW_HEIGHT; 555 557 const topPad = startIndex * ROW_HEIGHT; 556 558 557 - const selectedTrack = highlighted 558 - ? tracks.find((t) => t.id === highlighted) 559 - : undefined; 560 - const isCached = selectedTrack 561 - ? this.#cachedUris.value.has(selectedTrack.uri) 562 - : false; 559 + const highlightedTracks = this.$highlightedTracks.value; 560 + const selectedTracks = tracks.filter((t) => highlightedTracks.has(t.id)); 561 + const cachedUris = this.#cachedUris.value; 562 + const allCached = selectedTracks.length > 0 && 563 + selectedTracks.every((t) => cachedUris.has(t.uri)); 563 564 564 565 return html` 565 566 <link rel="stylesheet" href="vendor/98.css" /> ··· 605 606 .sunken-panel { 606 607 flex: 1; 607 608 min-height: 80px; 609 + outline: none; 608 610 } 609 611 610 612 :host([resizable]) .sunken-panel { ··· 776 778 </select> 777 779 </search> 778 780 779 - <div class="sunken-panel"> 781 + <div 782 + class="sunken-panel" 783 + tabindex="0" 784 + @keydown="${(/** @type {KeyboardEvent} */ e) => { 785 + if (e.key !== `ArrowUp` && e.key !== `ArrowDown`) return; 786 + e.preventDefault(); 787 + const allTracks = this.$provider.value?.tracks() ?? []; 788 + if (allTracks.length === 0) return; 789 + const current = this.$highlightedTrack.value; 790 + const currentIdx = current 791 + ? allTracks.findIndex((t) => t.id === current) 792 + : -1; 793 + const nextIdx = e.key === `ArrowUp` 794 + ? Math.max(0, currentIdx - 1) 795 + : Math.min(allTracks.length - 1, currentIdx + 1); 796 + const next = allTracks[nextIdx]; 797 + this.#anchorTrackId = next.id; 798 + this.$highlightedTracks.value = new Set([next.id]); 799 + this.$highlightedTrack.value = next.id; 800 + const panel = this.root().querySelector(`.sunken-panel`); 801 + if (panel) { 802 + const rowTop = nextIdx * ROW_HEIGHT; 803 + const rowBottom = rowTop + ROW_HEIGHT; 804 + if (rowTop < panel.scrollTop) { 805 + panel.scrollTop = rowTop; 806 + } else if (rowBottom > panel.scrollTop + panel.clientHeight) { 807 + panel.scrollTop = rowBottom - panel.clientHeight; 808 + } 809 + } 810 + }}" 811 + > 780 812 <table class="virtual-header"> 781 813 <thead> 782 814 <tr> ··· 820 852 : visibleTracks.map((track) => 821 853 html` 822 854 <tr 823 - class="${highlighted === track.id ? `highlighted` : ``}" 824 - @click="${() => this.$highlightedTrack.value = track.id}" 855 + class="${highlightedTracks.has(track.id) ? `highlighted` : ``}" 856 + @click="${(/** @type {MouseEvent} */ e) => { 857 + const idx = startIndex + visibleTracks.indexOf(track); 858 + if (e.shiftKey && this.#anchorTrackId !== null) { 859 + const anchorIdx = tracks.findIndex( 860 + (t) => t.id === this.#anchorTrackId, 861 + ); 862 + if (anchorIdx !== -1) { 863 + const from = Math.min(anchorIdx, idx); 864 + const to = Math.max(anchorIdx, idx); 865 + this.$highlightedTracks.value = new Set( 866 + tracks.slice(from, to + 1).map((t) => t.id), 867 + ); 868 + this.$highlightedTrack.value = track.id; 869 + return; 870 + } 871 + } 872 + this.#anchorTrackId = track.id; 873 + this.$highlightedTracks.value = new Set([track.id]); 874 + this.$highlightedTrack.value = track.id; 875 + }}" 825 876 @dblclick="${() => this.playTrack(track)}" 826 877 > 827 878 <td>${track.tags?.title}</td> ··· 837 888 838 889 <div class="field-row actions-row"> 839 890 <button 840 - ?disabled="${!selectedTrack}" 891 + ?disabled="${selectedTracks.length === 0}" 841 892 @click="${() => { 842 - if (!selectedTrack) return; 893 + if (selectedTracks.length === 0) return; 843 894 this.$queue.value?.add({ 844 895 inFront: true, 845 - trackIds: [selectedTrack.id], 896 + trackIds: selectedTracks.map((t) => t.id), 846 897 }); 847 898 }}" 848 899 > 849 900 Play next 850 901 </button> 851 902 <button 852 - ?disabled="${!selectedTrack}" 903 + ?disabled="${selectedTracks.length === 0}" 853 904 @click="${() => { 854 - if (!selectedTrack) return; 855 - this.$queue.value?.add({ trackIds: [selectedTrack.id] }); 905 + if (selectedTracks.length === 0) return; 906 + this.$queue.value?.add({ trackIds: selectedTracks.map((t) => t.id) }); 856 907 }}" 857 908 > 858 909 Add to queue 859 910 </button> 860 911 <button 861 - ?disabled="${!selectedTrack}" 912 + ?disabled="${selectedTracks.length === 0}" 862 913 @click="${() => { 863 - if (!selectedTrack) return; 914 + if (selectedTracks.length === 0) return; 864 915 this.#playlistPickerState.value = { 865 916 mode: `add`, 866 - tracks: [selectedTrack], 917 + tracks: selectedTracks, 867 918 }; 868 919 }}" 869 920 > ··· 872 923 ${this.$input.value 873 924 ? html` 874 925 <button 875 - ?disabled="${!selectedTrack}" 926 + ?disabled="${selectedTracks.length === 0}" 876 927 @click="${async () => { 877 - if (!selectedTrack) return; 878 - if (isCached) { 879 - await this.$input.value?.removeFromCache([selectedTrack.uri]); 928 + if (selectedTracks.length === 0) return; 929 + const uris = selectedTracks.map((t) => t.uri); 930 + if (allCached) { 931 + await this.$input.value?.removeFromCache(uris); 880 932 } else { 881 - await this.$input.value?.cache([selectedTrack.uri]); 933 + await this.$input.value?.cache(uris); 882 934 } 883 935 const updated = await this.$input.value?.listCached(); 884 936 if (updated) this.#cachedUris.value = new Set(updated); 885 937 }}" 886 938 > 887 - ${isCached ? `Remove from cache` : `Store in cache`} 939 + ${allCached ? `Remove from cache` : `Store in cache`} 888 940 </button> 889 941 ` 890 942 : ``}
+1
src/facets/themes/winamp/facet/index.html
··· 79 79 queue-engine-selector="de-queue" 80 80 scope-engine-selector="de-scope" 81 81 tracks-selector="do-scoped-tracks" 82 + input-selector="#input" 82 83 ></dtw-browser> 83 84 </dtw-window> 84 85
+1
src/facets/themes/winamp/facet/index.inline.js
··· 19 19 await foundation.orchestrator.queueAudio(); 20 20 await foundation.orchestrator.controller(); 21 21 await foundation.orchestrator.artwork(); 22 + await foundation.configurator.input(); 22 23 23 24 await import("~/facets/themes/winamp/browser/element.js"); 24 25 await import("~/facets/themes/winamp/window/element.js");
+135 -29
src/facets/themes/winamp/winamp/element.js
··· 4 4 query, 5 5 whenElementsDefined, 6 6 } from "~/common/element.js"; 7 - import { signal, untracked } from "~/common/signal.js"; 7 + import { batch, signal, untracked } from "~/common/signal.js"; 8 8 import { repeat } from "lit-html/directives/repeat.js"; 9 9 import { guard } from "lit-html/directives/guard.js"; 10 10 ··· 214 214 /** @type {ReturnType<typeof setTimeout> | undefined} */ 215 215 #playlistDebounce = undefined; 216 216 #dragState = signal( 217 - /** @type {{ fromIdx: number; toIdx: number; startY: number } | null} */ (null), 217 + /** @type {{ fromIdx: number; fromKey: string; toIdx: number; startY: number; multiDrag: boolean } | null} */ (null), 218 218 ); 219 219 /** @type {((e: MouseEvent) => void) | null} */ 220 220 #dragMouseMove = null; ··· 243 243 #marqueeStepInterval = undefined; 244 244 #marqueeText = signal(""); 245 245 #selectedIndex = signal(/** @type {number | null} */ (null)); 246 + #selectedIndices = signal(/** @type {Set<number>} */ (new Set())); 247 + #anchorIdx = /** @type {number | null} */ (null); 246 248 #mainOpen = signal(true); 247 249 #eqOpen = signal(true); 248 250 #mainShade = signal(false); ··· 1601 1603 }; 1602 1604 1603 1605 /** @param {number} idx */ 1604 - #selectTrack = (idx) => { 1606 + #selectTrack = (idx, shiftKey = false) => { 1605 1607 this.#selectedIndex.value = idx; 1608 + if (shiftKey && this.#anchorIdx !== null) { 1609 + const from = Math.min(this.#anchorIdx, idx); 1610 + const to = Math.max(this.#anchorIdx, idx); 1611 + const range = new Set(); 1612 + for (let i = from; i <= to; i++) range.add(i); 1613 + this.#selectedIndices.value = range; 1614 + } else { 1615 + this.#anchorIdx = idx; 1616 + this.#selectedIndices.value = new Set([idx]); 1617 + } 1618 + }; 1619 + 1620 + #removeTrack = () => { 1621 + const queue = this.$controller.value?.$queue.value; 1622 + if (!queue) return; 1623 + const indices = [...this.#selectedIndices.value].sort((a, b) => b - a); 1624 + if (indices.length === 0) return; 1625 + const { past, now, future } = this.#playlist.value; 1626 + const pLen = past.length; 1627 + const nLen = now ? 1 : 0; 1628 + const total = pLen + nLen + future.length; 1629 + for (const idx of indices) { 1630 + const item = idx < pLen ? past[idx] 1631 + : idx < pLen + nLen ? now 1632 + : future[idx - pLen - nLen]; 1633 + if (item) queue.expel({ key: item.key }); 1634 + } 1635 + const newTotal = total - indices.length; 1636 + const minIdx = Math.min(...indices); 1637 + this.#selectedIndices.value = new Set(); 1638 + this.#selectedIndex.value = newTotal <= 0 ? null : Math.min(minIdx, newTotal - 1); 1606 1639 }; 1607 1640 1608 1641 static #TRACK_HEIGHT = 13; ··· 1610 1643 /** 1611 1644 * @param {MouseEvent} e 1612 1645 * @param {number} idx 1646 + * @param {string} key 1613 1647 * @param {number} totalItems 1614 1648 */ 1615 - #onTrackMouseDown = (e, idx, totalItems) => { 1649 + #onTrackMouseDown = (e, idx, key, totalItems) => { 1616 1650 e.preventDefault(); 1617 - this.#dragState.value = { fromIdx: idx, toIdx: idx, startY: e.clientY }; 1651 + const multiDrag = this.#selectedIndices.value.size > 1 && 1652 + this.#selectedIndices.value.has(idx); 1653 + this.#dragState.value = { fromIdx: idx, fromKey: key, toIdx: idx, startY: e.clientY, multiDrag }; 1618 1654 1619 1655 this.#dragMouseMove = (mv) => { 1620 1656 const state = this.#dragState.value; ··· 1622 1658 const diff = Math.round( 1623 1659 (mv.clientY - state.startY) / WinampElement.#TRACK_HEIGHT, 1624 1660 ); 1625 - const toIdx = Math.max(0, Math.min(totalItems - 1, state.fromIdx + diff)); 1661 + let toIdx; 1662 + if (state.multiDrag) { 1663 + const sorted = [...this.#selectedIndices.value].sort((a, b) => a - b); 1664 + const clampedDiff = Math.max( 1665 + -sorted[0], 1666 + Math.min(totalItems - 1 - sorted[sorted.length - 1], diff), 1667 + ); 1668 + toIdx = state.fromIdx + clampedDiff; 1669 + } else { 1670 + toIdx = Math.max(0, Math.min(totalItems - 1, state.fromIdx + diff)); 1671 + } 1626 1672 if (toIdx !== state.toIdx) this.#dragState.value = { ...state, toIdx }; 1627 1673 }; 1628 1674 1629 1675 this.#dragMouseUp = () => { 1630 1676 const state = this.#dragState.value; 1631 - if (state) { 1632 - if (state.fromIdx !== state.toIdx) { 1633 - this.$controller.value?.$queue.value?.move({ 1634 - from: state.fromIdx, 1635 - to: state.toIdx, 1677 + if (state && state.fromIdx !== state.toIdx) { 1678 + const { past, now, future } = this.#playlist.value; 1679 + const all = [...past, ...(now ? [now] : []), ...future]; 1680 + const queue = this.$controller.value?.$queue.value; 1681 + 1682 + if (state.multiDrag) { 1683 + const sortedSel = [...this.#selectedIndices.value].sort((a, b) => a - b); 1684 + const delta = state.toIdx - state.fromIdx; // already clamped by mousemove 1685 + 1686 + // Build new array: each selected item shifts by delta, non-selected fill gaps 1687 + const selectedSet = new Set(sortedSel); 1688 + const result = new Array(all.length).fill(null); 1689 + sortedSel.forEach((i) => { result[i + delta] = all[i]; }); 1690 + const nonSelected = all.filter((_, i) => !selectedSet.has(i)); 1691 + let nsIdx = 0; 1692 + for (let i = 0; i < result.length; i++) { 1693 + if (result[i] === null) result[i] = nonSelected[nsIdx++]; 1694 + } 1695 + 1696 + // Commit moves to queue (order matters to preserve relative positions) 1697 + if (queue) { 1698 + const processOrder = delta > 0 ? [...sortedSel].reverse() : sortedSel; 1699 + for (const origIdx of processOrder) { 1700 + queue.move({ key: all[origIdx].key, to: origIdx + delta }); 1701 + } 1702 + } 1703 + 1704 + const nowIdx = now ? result.indexOf(now) : past.length; 1705 + clearTimeout(this.#playlistDebounce); 1706 + this.#anchorIdx = state.toIdx; 1707 + batch(() => { 1708 + this.#dragState.value = null; 1709 + this.#playlist.value = { 1710 + past: result.slice(0, nowIdx), 1711 + now: now ? (result[nowIdx] ?? null) : null, 1712 + future: result.slice(nowIdx + (now ? 1 : 0)), 1713 + }; 1714 + this.#selectedIndices.value = new Set(sortedSel.map((i) => i + delta)); 1715 + this.#selectedIndex.value = state.toIdx; 1636 1716 }); 1637 - // Immediately commit the reorder to #playlist so the item stays in 1638 - // place while the debounce is still pending. 1639 - const { past, now, future } = this.#playlist.value; 1640 - const all = [...past, ...(now ? [now] : []), ...future]; 1717 + } else { 1718 + if (queue) queue.move({ key: state.fromKey, to: state.toIdx }); 1641 1719 const [item] = all.splice(state.fromIdx, 1); 1642 1720 all.splice(state.toIdx, 0, item); 1643 1721 const nowIdx = now ? all.indexOf(now) : past.length; 1644 1722 clearTimeout(this.#playlistDebounce); 1645 - this.#playlist.value = { 1646 - past: all.slice(0, nowIdx), 1647 - now: now ? (all[nowIdx] ?? null) : null, 1648 - future: all.slice(nowIdx + (now ? 1 : 0)), 1649 - }; 1723 + this.#anchorIdx = state.toIdx; 1724 + batch(() => { 1725 + this.#dragState.value = null; 1726 + this.#playlist.value = { 1727 + past: all.slice(0, nowIdx), 1728 + now: now ? (all[nowIdx] ?? null) : null, 1729 + future: all.slice(nowIdx + (now ? 1 : 0)), 1730 + }; 1731 + this.#selectedIndices.value = new Set([state.toIdx]); 1732 + this.#selectedIndex.value = state.toIdx; 1733 + }); 1650 1734 } 1651 - this.#selectedIndex.value = state.toIdx; 1735 + } else { 1736 + this.#dragState.value = null; 1652 1737 } 1653 - this.#dragState.value = null; 1654 1738 if (this.#dragMouseMove) { 1655 1739 window.removeEventListener("mousemove", this.#dragMouseMove); 1656 1740 } ··· 1667 1751 1668 1752 /** @param {number} idx */ 1669 1753 #playTrack = (idx) => { 1754 + this.#anchorIdx = idx; 1755 + this.#selectedIndices.value = new Set([idx]); 1670 1756 this.#selectedIndex.value = idx; 1671 1757 const queue = this.$controller.value?.$queue.value; 1672 1758 if (!queue) return; ··· 1795 1881 1796 1882 // Apply local drag reorder for visual feedback (worker is only called on mouseup) 1797 1883 const dragState = this.#dragState.value; 1884 + const selectedIndices = this.#selectedIndices.value; 1798 1885 const displayItems = dragState && dragState.fromIdx !== dragState.toIdx 1799 1886 ? (() => { 1800 1887 const arr = [...allItems]; 1888 + if (dragState.multiDrag && selectedIndices.size > 1) { 1889 + const sortedSel = [...selectedIndices].sort((a, b) => a - b); 1890 + const delta = dragState.toIdx - dragState.fromIdx; 1891 + const selectedSet = new Set(sortedSel); 1892 + const result = new Array(arr.length).fill(null); 1893 + sortedSel.forEach((i) => { result[i + delta] = arr[i]; }); 1894 + const nonSelected = arr.filter((_, i) => !selectedSet.has(i)); 1895 + let nsIdx = 0; 1896 + for (let i = 0; i < result.length; i++) { 1897 + if (result[i] === null) result[i] = nonSelected[nsIdx++]; 1898 + } 1899 + return result; 1900 + } 1801 1901 const [item] = arr.splice(dragState.fromIdx, 1); 1802 1902 arr.splice(dragState.toIdx, 0, item); 1803 1903 return arr; ··· 1808 1908 const trackMap = col?.state === "loaded" 1809 1909 ? new Map(col.data.map((t) => [t.id, t])) 1810 1910 : new Map(); 1811 - const selectedIdx = this.#selectedIndex.value; 1911 + const selectedItemSet = new Set([...selectedIndices].map((i) => allItems[i])); 1812 1912 const nowIdx = nowItem ? displayItems.indexOf(nowItem) : -1; 1813 1913 const playlistRows = displayItems.map((item, i) => { 1814 1914 const track = trackMap.get(item.id); 1815 1915 const isCurrent = i === nowIdx; 1816 - const isSelected = dragState ? i === dragState.toIdx : selectedIdx === i; 1916 + const isSelected = dragState 1917 + ? (dragState.multiDrag && selectedIndices.size > 1 1918 + ? selectedItemSet.has(item) 1919 + : i === dragState.toIdx) 1920 + : selectedIndices.has(i); 1817 1921 const artist = track?.tags?.artist ?? ""; 1818 1922 const title = track?.tags?.title ?? ""; 1819 1923 const label = artist ? `${artist} - ${title}` : title; ··· 1825 1929 : ""; 1826 1930 const color = isCurrent ? "#FFFFFF" : "#00FF00"; 1827 1931 const bg = isSelected && !isCurrent ? "#0000FF" : "transparent"; 1828 - return { id: item.id, idx: i, n: i + 1, label, dur, color, bg }; 1932 + return { id: item.id, item, idx: i, n: i + 1, label, dur, color, bg }; 1829 1933 }); 1830 1934 1831 1935 // Playlist running time display: currentTrackDuration/totalPlaylistDuration ··· 2185 2289 > 2186 2290 ${guard([ 2187 2291 this.#playlist.value, 2188 - selectedIdx, 2292 + selectedIndices, 2189 2293 this.#dragState.value, 2190 2294 ], () => 2191 2295 html` ··· 2197 2301 class="track-cell" 2198 2302 style="color: ${r.color}; background-color: ${r 2199 2303 .bg};" 2200 - @click="${() => this.#selectTrack(r.idx)}" 2304 + @click="${(/** @type {MouseEvent} */ e) => this.#selectTrack(r.idx, e.shiftKey)}" 2201 2305 @dblclick="${() => this.#playTrack(r.idx)}" 2202 2306 @mousedown="${(/** @type {MouseEvent} */ e) => 2203 2307 this.#onTrackMouseDown( 2204 2308 e, 2205 2309 r.idx, 2310 + r.item.key, 2206 2311 allItems.length, 2207 2312 )}" 2208 2313 > ··· 2217 2322 class="track-cell" 2218 2323 style="color: ${r.color}; background-color: ${r 2219 2324 .bg}; cursor: grab;" 2220 - @click="${() => this.#selectTrack(r.idx)}" 2325 + @click="${(/** @type {MouseEvent} */ e) => this.#selectTrack(r.idx, e.shiftKey)}" 2221 2326 @dblclick="${() => this.#playTrack(r.idx)}" 2222 2327 @mousedown="${(/** @type {MouseEvent} */ e) => 2223 2328 this.#onTrackMouseDown( 2224 2329 e, 2225 2330 r.idx, 2331 + r.item.key, 2226 2332 allItems.length, 2227 2333 )}" 2228 2334 > ··· 2244 2350 <div id="playlist-add-menu" class="playlist-menu" @click="${this 2245 2351 .#openConnect}"> 2246 2352 </div> 2247 - <div id="playlist-remove-menu" class="playlist-menu"></div> 2353 + <div id="playlist-remove-menu" class="playlist-menu" @click="${this.#removeTrack}"></div> 2248 2354 <div id="playlist-selection-menu" class="playlist-menu"></div> 2249 2355 <div id="playlist-misc-menu" class="playlist-menu"></div> 2250 2356 </div>