diff --git a/src/components/engine/queue/element.js b/src/components/engine/queue/element.js index 62bbd2a3..73afe8da 100644 --- a/src/components/engine/queue/element.js +++ b/src/components/engine/queue/element.js @@ -26,6 +26,7 @@ class QueueEngine extends DiffuseElement { this.add = this.proxy.add; this.clear = this.proxy.clear; + this.expel = this.proxy.expel; this.fill = this.proxy.fill; this.move = this.proxy.move; this.shift = this.proxy.shift; diff --git a/src/components/engine/queue/types.d.ts b/src/components/engine/queue/types.d.ts index 1d1d1369..1800064d 100644 --- a/src/components/engine/queue/types.d.ts +++ b/src/components/engine/queue/types.d.ts @@ -6,6 +6,7 @@ export type Actions = { * Clear the `future()` items. */ clear: (args: { keepManual?: boolean }) => void; + expel: (args: { key: string }) => void; fill: ( args: { /** Always keep adding, even if the amount of non-manual items in the queue are passed the given `amount` */ @@ -14,7 +15,7 @@ export type Actions = { shuffled: boolean; }, ) => void; - move: (args: { from: number; to: number }) => void; + move: (args: { key: string; to: number }) => void; shift: () => void; supply: (args: { trackIds: string[] }) => void; unshift: () => void; @@ -22,6 +23,7 @@ export type Actions = { export type Item = { id: string; + key: string; manualEntry: boolean; }; diff --git a/src/components/engine/queue/worker.js b/src/components/engine/queue/worker.js index a77d30f1..ea509749 100644 --- a/src/components/engine/queue/worker.js +++ b/src/components/engine/queue/worker.js @@ -11,6 +11,9 @@ import { xxh32 } from "xxh32"; // STATE //////////////////////////////////////////// +let _key = 0; +const nextKey = () => String(++_key); + /** Ordered list of available track IDs. */ export const $lake = signal(/** @type {string[]} */ ([])); @@ -71,7 +74,7 @@ export const $supplyFingerprint = signal( */ export function add({ inFront, trackIds }) { const items = trackIds.map((id) => { - return { id, manualEntry: true }; + return { id, key: nextKey(), manualEntry: true }; }); if (inFront) { @@ -125,6 +128,85 @@ export function clear({ keepManual }) { : []; } +/** + * @type {Actions['expel']} + * + * @example Removes an item from the future by key + * ```js + * import { expel, $future } from "~/components/engine/queue/worker.js"; + * + * $future.value = [ + * { id: "a", key: "1", manualEntry: true }, + * { id: "b", key: "2", manualEntry: true }, + * ]; + * + * expel({ key: "1" }); + * + * if ($future.value.length !== 1) throw new Error("expected 1 item remaining"); + * if ($future.value[0].id !== "b") throw new Error("expected 'b' to remain"); + * ``` + * + * @example Removes the now-playing item by key, setting now to null + * ```js + * import { expel, $now } from "~/components/engine/queue/worker.js"; + * + * $now.value = { id: "a", key: "1", manualEntry: false }; + * + * expel({ key: "1" }); + * + * if ($now.value !== null) throw new Error("expected now to be null"); + * ``` + * + * @example Removes an item from the past by key + * ```js + * import { expel, $past } from "~/components/engine/queue/worker.js"; + * + * $past.value = [ + * { id: "a", key: "1", manualEntry: false }, + * { id: "b", key: "2", manualEntry: false }, + * ]; + * + * expel({ key: "1" }); + * + * if ($past.value.length !== 1) throw new Error("expected 1 item remaining"); + * if ($past.value[0].id !== "b") throw new Error("expected 'b' to remain"); + * ``` + * + * @example Does nothing for an unknown key + * ```js + * import { expel, $past, $now, $future } from "~/components/engine/queue/worker.js"; + * + * $past.value = [{ id: "a", key: "1", manualEntry: false }]; + * $now.value = { id: "b", key: "2", manualEntry: false }; + * $future.value = [{ id: "c", key: "3", manualEntry: false }]; + * + * expel({ key: "z" }); + * + * if ($past.value.length !== 1) throw new Error("past should be unchanged"); + * if ($now.value?.id !== "b") throw new Error("now should be unchanged"); + * if ($future.value.length !== 1) throw new Error("future should be unchanged"); + * ``` + */ +export function expel({ key }) { + const pastIdx = $past.value.findIndex((i) => i.key === key); + if (pastIdx !== -1) { + const p = [...$past.value]; + p.splice(pastIdx, 1); + $past.value = p; + return; + } + if ($now.value?.key === key) { + $now.value = null; + return; + } + const futureIdx = $future.value.findIndex((i) => i.key === key); + if (futureIdx !== -1) { + const f = [...$future.value]; + f.splice(futureIdx, 1); + $future.value = f; + } +} + /** * @type {Actions['fill']} */ @@ -147,12 +229,12 @@ export function fill({ augment, amount, shuffled }) { * import { move, $future } from "~/components/engine/queue/worker.js"; * * $future.value = [ - * { id: "a", manualEntry: true }, - * { id: "b", manualEntry: true }, - * { id: "c", manualEntry: true }, + * { id: "a", key: "1", manualEntry: true }, + * { id: "b", key: "2", manualEntry: true }, + * { id: "c", key: "3", manualEntry: true }, * ]; * - * move({ from: 0, to: 2 }); + * move({ key: "1", to: 2 }); * * if ($future.value[0].id !== "b") throw new Error("expected 'b' first"); * if ($future.value[1].id !== "c") throw new Error("expected 'c' second"); @@ -164,12 +246,12 @@ export function fill({ augment, amount, shuffled }) { * import { move, $future } from "~/components/engine/queue/worker.js"; * * $future.value = [ - * { id: "a", manualEntry: true }, - * { id: "b", manualEntry: true }, - * { id: "c", manualEntry: true }, + * { id: "a", key: "1", manualEntry: true }, + * { id: "b", key: "2", manualEntry: true }, + * { id: "c", key: "3", manualEntry: true }, * ]; * - * move({ from: 2, to: 0 }); + * move({ key: "3", to: 0 }); * * if ($future.value[0].id !== "c") throw new Error("expected 'c' first"); * if ($future.value[1].id !== "a") throw new Error("expected 'a' second"); @@ -180,12 +262,12 @@ export function fill({ augment, amount, shuffled }) { * ```js * import { move, $past, $now, $future } from "~/components/engine/queue/worker.js"; * - * $past.value = [{ id: "a", manualEntry: false }]; - * $now.value = { id: "b", manualEntry: false }; - * $future.value = [{ id: "c", manualEntry: false }]; + * $past.value = [{ id: "a", key: "1", manualEntry: false }]; + * $now.value = { id: "b", key: "2", manualEntry: false }; + * $future.value = [{ id: "c", key: "3", manualEntry: false }]; * * // flat list is [a(0), b(1), c(2)]; moving c to front → [c, a, b] - * move({ from: 2, to: 0 }); + * move({ key: "3", to: 0 }); * * if ($now.value?.id !== "b") throw new Error("now should still be 'b'"); * if ($past.value[0]?.id !== "c") throw new Error("expected 'c' first in past"); @@ -193,53 +275,86 @@ export function fill({ augment, amount, shuffled }) { * if ($future.value.length !== 0) throw new Error("future should be empty"); * ``` * - * @example Does nothing when from equals to + * @example Does nothing when the item is already at the target position * ```js * import { move, $future } from "~/components/engine/queue/worker.js"; * - * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; + * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }]; * - * move({ from: 1, to: 1 }); + * move({ key: "2", to: 1 }); * * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); * ``` * - * @example Does nothing for out-of-bounds indices + * @example Does nothing for out-of-bounds target or unknown key * ```js * import { move, $future } from "~/components/engine/queue/worker.js"; * - * $future.value = [{ id: "a", manualEntry: true }, { id: "b", manualEntry: true }]; + * $future.value = [{ id: "a", key: "1", manualEntry: true }, { id: "b", key: "2", manualEntry: true }]; * - * move({ from: 0, to: 99 }); + * move({ key: "1", to: 99 }); + * move({ key: "z", to: 0 }); * * if ($future.value[0].id !== "a") throw new Error("order should be unchanged"); * if ($future.value[1].id !== "b") throw new Error("order should be unchanged"); * ``` */ -export function move({ from, to }) { - const all = [ - ...$past.value, - ...($now.value ? [$now.value] : []), - ...$future.value, - ]; - - if (from === to || from < 0 || to < 0 || from >= all.length || to >= all.length) return; +export function move({ key, to }) { + const past = $past.value; + const now = $now.value; + const future = $future.value; + const pLen = past.length; + const nLen = now ? 1 : 0; + const futureStart = pLen + nLen; + const total = futureStart + future.length; + + let from = past.findIndex((i) => i.key === key); + if (from === -1 && now?.key === key) from = pLen; + if (from === -1) { + const fi = future.findIndex((i) => i.key === key); + if (fi !== -1) from = futureStart + fi; + } - const [item] = all.splice(from, 1); - all.splice(to, 0, item); + if (from === -1 || from === to || to < 0 || to >= total) return; - const now = $now.value; - if (now) { - const nowIdx = all.indexOf(now); - $past.value = all.slice(0, nowIdx); - $now.value = all[nowIdx] ?? null; - $future.value = all.slice(nowIdx + 1); - } else { - const pastLen = $past.value.length; - $past.value = all.slice(0, pastLen); - $future.value = all.slice(pastLen); + // Compute now's new flat index after the move + let nowIdx = pLen; + if (nLen) { + if (from === pLen) nowIdx = to; + else if (from < to && pLen > from && pLen <= to) nowIdx = pLen - 1; + else if (from > to && pLen >= to && pLen < from) nowIdx = pLen + 1; } + + // Map a post-move flat index back to the original flat index + const origIdx = (/** @type {number} */ i) => { + if (from < to) { + if (i < from) return i; + if (i < to) return i + 1; + if (i === to) return from; + } else { + if (i < to) return i; + if (i === to) return from; + if (i <= from) return i - 1; + } + return i; + }; + + const flatGet = (/** @type {number} */ i) => { + const j = origIdx(i); + return j < pLen ? past[j] : j < futureStart ? now : future[j - futureStart]; + }; + + $past.value = + /** @type {Item[]} */ (Array.from( + { length: nowIdx }, + (_, i) => flatGet(i), + )); + $future.value = + /** @type {Item[]} */ (Array.from( + { length: total - nowIdx - nLen }, + (_, i) => flatGet(nowIdx + nLen + i), + )); } /** @@ -343,6 +458,7 @@ ostiary((context, _firstConnection, _connectionId) => { rpc(context, { add, clear, + expel, fill, move, shift, @@ -480,7 +596,7 @@ export function fillSequentially(fillAmount, future) { if (currIndex > maxIndex) currIndex = 0; const id = $lake.value[currIndex]; if (id) { - autoItems.push({ id, manualEntry: false }); + autoItems.push({ id, key: nextKey(), manualEntry: false }); } currIndex++; } @@ -554,30 +670,29 @@ export function fillShuffle(fillAmount, future, autoFutureCount) { if ($now.value) excludeIds.add($now.value.id); future.forEach((i) => excludeIds.add(i.id)); - let pool = $lake.value - .filter((id) => !excludeIds.has(id)) - .map((id) => ({ id, manualEntry: false })); + let pool = $lake.value.filter((id) => !excludeIds.has(id)); // Fallback: if everything has been played/is playing/is queued, use tracks not in past or now if (pool.length === 0) { const pastAndNowIds = new Set($past.value.map((i) => i.id)); if ($now.value) pastAndNowIds.add($now.value.id); - pool = $lake.value - .filter((id) => !pastAndNowIds.has(id)) - .map((id) => ({ id, manualEntry: false })); + pool = $lake.value.filter((id) => !pastAndNowIds.has(id)); } // Final fallback: everything has been played, use the full lake if (pool.length === 0) { - pool = $lake.value.map((id) => ({ id, manualEntry: false })); + pool = [...$lake.value]; } - const poolSelection = arrayShuffle(pool).slice( + const selected = arrayShuffle(pool).slice( 0, Math.max(0, fillAmount - autoFutureCount), ); - return [...future, ...poolSelection]; + return [ + ...future, + ...selected.map((id) => ({ id, key: nextKey(), manualEntry: false })), + ]; } /** diff --git a/src/facets/playback/queue/index.inline.js b/src/facets/playback/queue/index.inline.js index c191bb31..e6f26094 100644 --- a/src/facets/playback/queue/index.inline.js +++ b/src/facets/playback/queue/index.inline.js @@ -139,7 +139,7 @@ effect(() => { title="Move up" ?disabled="${i === 0}" @click="${() => - queue.move({ from: offset + i, to: offset + i - 1 })}" + queue.move({ key: item.key, to: offset + i - 1 })}" > @@ -148,7 +148,7 @@ effect(() => { title="Move down" ?disabled="${i === future.length - 1}" @click="${() => - queue.move({ from: offset + i, to: offset + i + 1 })}" + queue.move({ key: item.key, to: offset + i + 1 })}" > diff --git a/src/facets/themes/winamp/browser/element.js b/src/facets/themes/winamp/browser/element.js index cd22b28f..a24d7658 100644 --- a/src/facets/themes/winamp/browser/element.js +++ b/src/facets/themes/winamp/browser/element.js @@ -52,6 +52,8 @@ class Browser extends DiffuseElement { ); $highlightedTrack = signal(/** @type {string | null} */ (null)); + $highlightedTracks = signal(/** @type {Set} */ (new Set())); + #anchorTrackId = /** @type {string | null} */ (null); $input = signal( /** @type {import("~/components/configurator/input/element.js").CLASS | undefined} */ (undefined), @@ -554,12 +556,11 @@ class Browser extends DiffuseElement { const totalHeight = totalTracks * ROW_HEIGHT; const topPad = startIndex * ROW_HEIGHT; - const selectedTrack = highlighted - ? tracks.find((t) => t.id === highlighted) - : undefined; - const isCached = selectedTrack - ? this.#cachedUris.value.has(selectedTrack.uri) - : false; + const highlightedTracks = this.$highlightedTracks.value; + const selectedTracks = tracks.filter((t) => highlightedTracks.has(t.id)); + const cachedUris = this.#cachedUris.value; + const allCached = selectedTracks.length > 0 && + selectedTracks.every((t) => cachedUris.has(t.uri)); return html` @@ -605,6 +606,7 @@ class Browser extends DiffuseElement { .sunken-panel { flex: 1; min-height: 80px; + outline: none; } :host([resizable]) .sunken-panel { @@ -776,7 +778,37 @@ class Browser extends DiffuseElement { -
+
@@ -820,8 +852,27 @@ class Browser extends DiffuseElement { : visibleTracks.map((track) => html` @@ -837,33 +888,33 @@ class Browser extends DiffuseElement {
` : ``} diff --git a/src/facets/themes/winamp/facet/index.html b/src/facets/themes/winamp/facet/index.html index 6b1c8ed9..adde6c43 100644 --- a/src/facets/themes/winamp/facet/index.html +++ b/src/facets/themes/winamp/facet/index.html @@ -79,6 +79,7 @@ queue-engine-selector="de-queue" scope-engine-selector="de-scope" tracks-selector="do-scoped-tracks" + input-selector="#input" > diff --git a/src/facets/themes/winamp/facet/index.inline.js b/src/facets/themes/winamp/facet/index.inline.js index c1d36b6c..39260be5 100644 --- a/src/facets/themes/winamp/facet/index.inline.js +++ b/src/facets/themes/winamp/facet/index.inline.js @@ -19,6 +19,7 @@ await foundation.orchestrator.processTracks({ disableWhenReady: true }); await foundation.orchestrator.queueAudio(); await foundation.orchestrator.controller(); await foundation.orchestrator.artwork(); +await foundation.configurator.input(); await import("~/facets/themes/winamp/browser/element.js"); await import("~/facets/themes/winamp/window/element.js"); diff --git a/src/facets/themes/winamp/winamp/element.js b/src/facets/themes/winamp/winamp/element.js index f85122eb..a775220b 100644 --- a/src/facets/themes/winamp/winamp/element.js +++ b/src/facets/themes/winamp/winamp/element.js @@ -4,7 +4,7 @@ import { query, whenElementsDefined, } from "~/common/element.js"; -import { signal, untracked } from "~/common/signal.js"; +import { batch, signal, untracked } from "~/common/signal.js"; import { repeat } from "lit-html/directives/repeat.js"; import { guard } from "lit-html/directives/guard.js"; @@ -214,7 +214,7 @@ class WinampElement extends DiffuseElement { /** @type {ReturnType | undefined} */ #playlistDebounce = undefined; #dragState = signal( - /** @type {{ fromIdx: number; toIdx: number; startY: number } | null} */ (null), + /** @type {{ fromIdx: number; fromKey: string; toIdx: number; startY: number; multiDrag: boolean } | null} */ (null), ); /** @type {((e: MouseEvent) => void) | null} */ #dragMouseMove = null; @@ -243,6 +243,8 @@ class WinampElement extends DiffuseElement { #marqueeStepInterval = undefined; #marqueeText = signal(""); #selectedIndex = signal(/** @type {number | null} */ (null)); + #selectedIndices = signal(/** @type {Set} */ (new Set())); + #anchorIdx = /** @type {number | null} */ (null); #mainOpen = signal(true); #eqOpen = signal(true); #mainShade = signal(false); @@ -1601,8 +1603,39 @@ class WinampElement extends DiffuseElement { }; /** @param {number} idx */ - #selectTrack = (idx) => { + #selectTrack = (idx, shiftKey = false) => { this.#selectedIndex.value = idx; + if (shiftKey && this.#anchorIdx !== null) { + const from = Math.min(this.#anchorIdx, idx); + const to = Math.max(this.#anchorIdx, idx); + const range = new Set(); + for (let i = from; i <= to; i++) range.add(i); + this.#selectedIndices.value = range; + } else { + this.#anchorIdx = idx; + this.#selectedIndices.value = new Set([idx]); + } + }; + + #removeTrack = () => { + const queue = this.$controller.value?.$queue.value; + if (!queue) return; + const indices = [...this.#selectedIndices.value].sort((a, b) => b - a); + if (indices.length === 0) return; + const { past, now, future } = this.#playlist.value; + const pLen = past.length; + const nLen = now ? 1 : 0; + const total = pLen + nLen + future.length; + for (const idx of indices) { + const item = idx < pLen ? past[idx] + : idx < pLen + nLen ? now + : future[idx - pLen - nLen]; + if (item) queue.expel({ key: item.key }); + } + const newTotal = total - indices.length; + const minIdx = Math.min(...indices); + this.#selectedIndices.value = new Set(); + this.#selectedIndex.value = newTotal <= 0 ? null : Math.min(minIdx, newTotal - 1); }; static #TRACK_HEIGHT = 13; @@ -1610,11 +1643,14 @@ class WinampElement extends DiffuseElement { /** * @param {MouseEvent} e * @param {number} idx + * @param {string} key * @param {number} totalItems */ - #onTrackMouseDown = (e, idx, totalItems) => { + #onTrackMouseDown = (e, idx, key, totalItems) => { e.preventDefault(); - this.#dragState.value = { fromIdx: idx, toIdx: idx, startY: e.clientY }; + const multiDrag = this.#selectedIndices.value.size > 1 && + this.#selectedIndices.value.has(idx); + this.#dragState.value = { fromIdx: idx, fromKey: key, toIdx: idx, startY: e.clientY, multiDrag }; this.#dragMouseMove = (mv) => { const state = this.#dragState.value; @@ -1622,35 +1658,83 @@ class WinampElement extends DiffuseElement { const diff = Math.round( (mv.clientY - state.startY) / WinampElement.#TRACK_HEIGHT, ); - const toIdx = Math.max(0, Math.min(totalItems - 1, state.fromIdx + diff)); + let toIdx; + if (state.multiDrag) { + const sorted = [...this.#selectedIndices.value].sort((a, b) => a - b); + const clampedDiff = Math.max( + -sorted[0], + Math.min(totalItems - 1 - sorted[sorted.length - 1], diff), + ); + toIdx = state.fromIdx + clampedDiff; + } else { + toIdx = Math.max(0, Math.min(totalItems - 1, state.fromIdx + diff)); + } if (toIdx !== state.toIdx) this.#dragState.value = { ...state, toIdx }; }; this.#dragMouseUp = () => { const state = this.#dragState.value; - if (state) { - if (state.fromIdx !== state.toIdx) { - this.$controller.value?.$queue.value?.move({ - from: state.fromIdx, - to: state.toIdx, + if (state && state.fromIdx !== state.toIdx) { + const { past, now, future } = this.#playlist.value; + const all = [...past, ...(now ? [now] : []), ...future]; + const queue = this.$controller.value?.$queue.value; + + if (state.multiDrag) { + const sortedSel = [...this.#selectedIndices.value].sort((a, b) => a - b); + const delta = state.toIdx - state.fromIdx; // already clamped by mousemove + + // Build new array: each selected item shifts by delta, non-selected fill gaps + const selectedSet = new Set(sortedSel); + const result = new Array(all.length).fill(null); + sortedSel.forEach((i) => { result[i + delta] = all[i]; }); + const nonSelected = all.filter((_, i) => !selectedSet.has(i)); + let nsIdx = 0; + for (let i = 0; i < result.length; i++) { + if (result[i] === null) result[i] = nonSelected[nsIdx++]; + } + + // Commit moves to queue (order matters to preserve relative positions) + if (queue) { + const processOrder = delta > 0 ? [...sortedSel].reverse() : sortedSel; + for (const origIdx of processOrder) { + queue.move({ key: all[origIdx].key, to: origIdx + delta }); + } + } + + const nowIdx = now ? result.indexOf(now) : past.length; + clearTimeout(this.#playlistDebounce); + this.#anchorIdx = state.toIdx; + batch(() => { + this.#dragState.value = null; + this.#playlist.value = { + past: result.slice(0, nowIdx), + now: now ? (result[nowIdx] ?? null) : null, + future: result.slice(nowIdx + (now ? 1 : 0)), + }; + this.#selectedIndices.value = new Set(sortedSel.map((i) => i + delta)); + this.#selectedIndex.value = state.toIdx; }); - // Immediately commit the reorder to #playlist so the item stays in - // place while the debounce is still pending. - const { past, now, future } = this.#playlist.value; - const all = [...past, ...(now ? [now] : []), ...future]; + } else { + if (queue) queue.move({ key: state.fromKey, to: state.toIdx }); const [item] = all.splice(state.fromIdx, 1); all.splice(state.toIdx, 0, item); const nowIdx = now ? all.indexOf(now) : past.length; clearTimeout(this.#playlistDebounce); - this.#playlist.value = { - past: all.slice(0, nowIdx), - now: now ? (all[nowIdx] ?? null) : null, - future: all.slice(nowIdx + (now ? 1 : 0)), - }; + this.#anchorIdx = state.toIdx; + batch(() => { + this.#dragState.value = null; + this.#playlist.value = { + past: all.slice(0, nowIdx), + now: now ? (all[nowIdx] ?? null) : null, + future: all.slice(nowIdx + (now ? 1 : 0)), + }; + this.#selectedIndices.value = new Set([state.toIdx]); + this.#selectedIndex.value = state.toIdx; + }); } - this.#selectedIndex.value = state.toIdx; + } else { + this.#dragState.value = null; } - this.#dragState.value = null; if (this.#dragMouseMove) { window.removeEventListener("mousemove", this.#dragMouseMove); } @@ -1667,6 +1751,8 @@ class WinampElement extends DiffuseElement { /** @param {number} idx */ #playTrack = (idx) => { + this.#anchorIdx = idx; + this.#selectedIndices.value = new Set([idx]); this.#selectedIndex.value = idx; const queue = this.$controller.value?.$queue.value; if (!queue) return; @@ -1795,9 +1881,23 @@ class WinampElement extends DiffuseElement { // Apply local drag reorder for visual feedback (worker is only called on mouseup) const dragState = this.#dragState.value; + const selectedIndices = this.#selectedIndices.value; const displayItems = dragState && dragState.fromIdx !== dragState.toIdx ? (() => { const arr = [...allItems]; + if (dragState.multiDrag && selectedIndices.size > 1) { + const sortedSel = [...selectedIndices].sort((a, b) => a - b); + const delta = dragState.toIdx - dragState.fromIdx; + const selectedSet = new Set(sortedSel); + const result = new Array(arr.length).fill(null); + sortedSel.forEach((i) => { result[i + delta] = arr[i]; }); + const nonSelected = arr.filter((_, i) => !selectedSet.has(i)); + let nsIdx = 0; + for (let i = 0; i < result.length; i++) { + if (result[i] === null) result[i] = nonSelected[nsIdx++]; + } + return result; + } const [item] = arr.splice(dragState.fromIdx, 1); arr.splice(dragState.toIdx, 0, item); return arr; @@ -1808,12 +1908,16 @@ class WinampElement extends DiffuseElement { const trackMap = col?.state === "loaded" ? new Map(col.data.map((t) => [t.id, t])) : new Map(); - const selectedIdx = this.#selectedIndex.value; + const selectedItemSet = new Set([...selectedIndices].map((i) => allItems[i])); const nowIdx = nowItem ? displayItems.indexOf(nowItem) : -1; const playlistRows = displayItems.map((item, i) => { const track = trackMap.get(item.id); const isCurrent = i === nowIdx; - const isSelected = dragState ? i === dragState.toIdx : selectedIdx === i; + const isSelected = dragState + ? (dragState.multiDrag && selectedIndices.size > 1 + ? selectedItemSet.has(item) + : i === dragState.toIdx) + : selectedIndices.has(i); const artist = track?.tags?.artist ?? ""; const title = track?.tags?.title ?? ""; const label = artist ? `${artist} - ${title}` : title; @@ -1825,7 +1929,7 @@ class WinampElement extends DiffuseElement { : ""; const color = isCurrent ? "#FFFFFF" : "#00FF00"; const bg = isSelected && !isCurrent ? "#0000FF" : "transparent"; - return { id: item.id, idx: i, n: i + 1, label, dur, color, bg }; + return { id: item.id, item, idx: i, n: i + 1, label, dur, color, bg }; }); // Playlist running time display: currentTrackDuration/totalPlaylistDuration @@ -2185,7 +2289,7 @@ class WinampElement extends DiffuseElement { > ${guard([ this.#playlist.value, - selectedIdx, + selectedIndices, this.#dragState.value, ], () => html` @@ -2197,12 +2301,13 @@ class WinampElement extends DiffuseElement { class="track-cell" style="color: ${r.color}; background-color: ${r .bg};" - @click="${() => this.#selectTrack(r.idx)}" + @click="${(/** @type {MouseEvent} */ e) => this.#selectTrack(r.idx, e.shiftKey)}" @dblclick="${() => this.#playTrack(r.idx)}" @mousedown="${(/** @type {MouseEvent} */ e) => this.#onTrackMouseDown( e, r.idx, + r.item.key, allItems.length, )}" > @@ -2217,12 +2322,13 @@ class WinampElement extends DiffuseElement { class="track-cell" style="color: ${r.color}; background-color: ${r .bg}; cursor: grab;" - @click="${() => this.#selectTrack(r.idx)}" + @click="${(/** @type {MouseEvent} */ e) => this.#selectTrack(r.idx, e.shiftKey)}" @dblclick="${() => this.#playTrack(r.idx)}" @mousedown="${(/** @type {MouseEvent} */ e) => this.#onTrackMouseDown( e, r.idx, + r.item.key, allItems.length, )}" > @@ -2244,7 +2350,7 @@ class WinampElement extends DiffuseElement {
-
+
${track.tags?.title}